Skip to main content

ad_plugins_rs/
file_hdf5.rs

1use std::path::{Path, PathBuf};
2
3use ad_core_rs::attributes::{NDAttrDataType, NDAttrSource, NDAttrValue, NDAttribute};
4use ad_core_rs::codec::{Codec, CodecName};
5use ad_core_rs::error::{ADError, ADResult};
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, ParamUpdate, PluginParamSnapshot, ProcessResult,
12};
13
14use rust_hdf5::H5File;
15use rust_hdf5::format::messages::datatype::{ByteOrder, DatatypeMessage};
16use rust_hdf5::format::messages::filter::{
17    FILTER_BLOSC, FILTER_BSHUF, FILTER_JPEG, FILTER_SZIP, Filter, FilterPipeline,
18};
19use rust_hdf5::swmr::SwmrFileWriter;
20
21use crate::hdf5_layout::Hdf5Layout;
22
23/// C ADCore compression type enum values (matching NDFileHDF5.h).
24const COMPRESS_NONE: i32 = 0;
25const COMPRESS_NBIT: i32 = 1;
26const COMPRESS_SZIP: i32 = 2;
27const COMPRESS_ZLIB: i32 = 3;
28const COMPRESS_BLOSC: i32 = 4;
29const COMPRESS_BSHUF: i32 = 5;
30const COMPRESS_LZ4: i32 = 6;
31const COMPRESS_JPEG: i32 = 7;
32
33/// SZIP nearest-neighbor coding option mask (`H5_SZIP_NN_OPTION_MASK`,
34/// H5Zpublic.h); C passes this to `H5Pset_szip` (NDFileHDF5.cpp:3372).
35const SZIP_NN_OPTION_MASK: u32 = 32;
36
37/// C ADCore BLOSC compressor sub-types.
38const BLOSC_LZ: i32 = 0;
39const BLOSC_LZ4: i32 = 1;
40const BLOSC_LZ4HC: i32 = 2;
41const BLOSC_SNAPPY: i32 = 3;
42const BLOSC_ZLIB: i32 = 4;
43const BLOSC_ZSTD: i32 = 5;
44
45/// Maximum number of extra dimensions (C `MAXEXTRADIMS`).
46const MAX_EXTRA_DIMS: usize = 10;
47
48/// Name of the HDF5 attribute that records the NDArray data type ordinal
49/// (matches C `NDDataType_t`). `read_file` uses it to recover the exact type.
50const DTYPE_ATTR: &str = "NDArrayDataType";
51
52/// Built-in NeXus layout used when no user layout XML is configured, mirroring
53/// C `LayoutXML::DEFAULT_LAYOUT` (NDFileHDF5LayoutXML.cpp:43-70) which C loads
54/// via `layout.load_xml()` for an empty `HDF5_layoutFilename` (NDFileHDF5.cpp:
55/// 3896-3906). It places the detector image at `/entry/instrument/detector/data`
56/// inside a full NXentry/NXinstrument/NXdetector/NXcollection/NXdata tree, with
57/// an `/entry/data/data` hardlink to the detector dataset.
58const DEFAULT_LAYOUT_XML: &str = r#"
59<hdf5_layout>
60<group name="entry">
61  <attribute name="NX_class" source="constant" value="NXentry" type="string"></attribute>
62  <group name="instrument">
63    <attribute name="NX_class" source="constant" value="NXinstrument" type="string"></attribute>
64    <group name="detector">
65      <attribute name="NX_class" source="constant" value="NXdetector" type="string"></attribute>
66      <dataset name="data" source="detector" det_default="true">
67        <attribute name="signal" source="constant" value="1" type="int"></attribute>
68      </dataset>
69      <group name="NDAttributes">
70        <attribute name="NX_class" source="constant" value="NXcollection" type="string"></attribute>
71        <dataset name="ColorMode" source="ndattribute" ndattribute="ColorMode"></dataset>
72      </group>
73    </group>
74    <group name="NDAttributes" ndattr_default="true">
75      <attribute name="NX_class" source="constant" value="NXcollection" type="string"></attribute>
76    </group>
77    <group name="performance">
78      <dataset name="timestamp"></dataset>
79    </group>
80  </group>
81  <group name="data">
82    <attribute name="NX_class" source="constant" value="NXdata" type="string"></attribute>
83    <hardlink name="data" target="/entry/instrument/detector/data"></hardlink>
84  </group>
85</group>
86</hdf5_layout>
87"#;
88
89/// User-controlled chunk geometry (C `HDF5_*Chunks` params).
90#[derive(Clone)]
91struct ChunkConfig {
92    /// `HDF5_chunkSizeAuto` — when true, ignore the explicit row/col/frame
93    /// values and let the writer pick (full-frame spatial, one frame deep).
94    auto: bool,
95    n_row_chunks: usize,
96    n_col_chunks: usize,
97    n_frames_chunks: usize,
98    /// `HDF5_NDAttributeChunk` — chunk depth for NDAttribute datasets.
99    /// `0` means auto (C param default), resolved by `attribute_chunking`.
100    ndattr_chunk: usize,
101}
102
103impl Default for ChunkConfig {
104    fn default() -> Self {
105        Self {
106            auto: true,
107            n_row_chunks: 0,
108            n_col_chunks: 0,
109            n_frames_chunks: 1,
110            ndattr_chunk: 0,
111        }
112    }
113}
114
115/// One extra-dimension entry (C `HDF5_extraDimSizeN` / `HDF5_extraDimNameN`).
116#[derive(Clone, Default)]
117struct ExtraDim {
118    size: usize,
119    name: String,
120}
121
122/// State for a single open attribute time-series dataset (one per NDAttribute).
123/// Mirrors C++ `NDFileHDF5AttributeDataset`: a 1-D extensible dataset holding
124/// one numeric (or string) value per frame.
125struct AttributeDataset {
126    name: String,
127    /// The three remaining self-describing strings C writes as HDF5 attributes
128    /// on each attribute dataset (NDFileHDF5.cpp:2715, 2785-2788): description,
129    /// source, and the source-type string. Captured at create time because the
130    /// source NDArray is gone by flush.
131    description: String,
132    source: String,
133    source_type: String,
134    data_type: NDAttrDataType,
135    /// Raw little-endian bytes accumulated, one element per frame.
136    buffer: Vec<u8>,
137    frames: usize,
138}
139
140/// C `NDAttribute` source-type string (NDAttribute.cpp:49-62), recorded as the
141/// `NDAttrSourceType` HDF5 attribute on each NDAttribute dataset.
142fn nd_attr_source_type_string(source: &NDAttrSource) -> &'static str {
143    match source {
144        NDAttrSource::Driver => "NDAttrSourceDriver",
145        NDAttrSource::Param { .. } => "NDAttrSourceParam",
146        NDAttrSource::EpicsPV(_) => "NDAttrSourceEPICSPV",
147        NDAttrSource::Function(_) => "NDAttrSourceFunct",
148        NDAttrSource::Constant(_) => "NDAttrSourceConst",
149        NDAttrSource::Undefined => "Undefined",
150    }
151}
152
153impl AttributeDataset {
154    fn new(attr: &NDAttribute) -> Self {
155        Self {
156            name: attr.name.clone(),
157            description: attr.description.clone(),
158            source: attr.source.source_string().to_string(),
159            source_type: nd_attr_source_type_string(&attr.source).to_string(),
160            data_type: attr.value.data_type(),
161            buffer: Vec::new(),
162            frames: 0,
163        }
164    }
165
166    /// Element byte width for this attribute's numeric type. Strings are
167    /// stored as a fixed-width field (matching C++ `MAX_ATTRIBUTE_STRING_SIZE`).
168    fn element_size(&self) -> usize {
169        match self.data_type {
170            NDAttrDataType::Int8 | NDAttrDataType::UInt8 => 1,
171            NDAttrDataType::Int16 | NDAttrDataType::UInt16 => 2,
172            NDAttrDataType::Int32 | NDAttrDataType::UInt32 | NDAttrDataType::Float32 => 4,
173            NDAttrDataType::Int64 | NDAttrDataType::UInt64 | NDAttrDataType::Float64 => 8,
174            NDAttrDataType::String => MAX_ATTRIBUTE_STRING_SIZE,
175        }
176    }
177
178    /// Append one frame's value, encoding it to the dataset's native type.
179    fn push(&mut self, value: &NDAttrValue) {
180        let es = self.element_size();
181        let mut bytes = vec![0u8; es];
182        match self.data_type {
183            NDAttrDataType::Int8 => bytes[0] = value.as_i64().unwrap_or(0) as i8 as u8,
184            NDAttrDataType::UInt8 => bytes[0] = value.as_i64().unwrap_or(0) as u8,
185            NDAttrDataType::Int16 => {
186                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as i16).to_le_bytes())
187            }
188            NDAttrDataType::UInt16 => {
189                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as u16).to_le_bytes())
190            }
191            NDAttrDataType::Int32 => {
192                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as i32).to_le_bytes())
193            }
194            NDAttrDataType::UInt32 => {
195                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as u32).to_le_bytes())
196            }
197            NDAttrDataType::Int64 => {
198                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0)).to_le_bytes())
199            }
200            NDAttrDataType::UInt64 => {
201                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as u64).to_le_bytes())
202            }
203            NDAttrDataType::Float32 => {
204                bytes.copy_from_slice(&(value.as_f64().unwrap_or(0.0) as f32).to_le_bytes())
205            }
206            NDAttrDataType::Float64 => {
207                bytes.copy_from_slice(&(value.as_f64().unwrap_or(0.0)).to_le_bytes())
208            }
209            NDAttrDataType::String => {
210                let s = value.as_string();
211                let src = s.as_bytes();
212                let n = src.len().min(es - 1);
213                bytes[..n].copy_from_slice(&src[..n]);
214            }
215        }
216        self.buffer.extend_from_slice(&bytes);
217        self.frames += 1;
218    }
219}
220
221/// Fixed string field width for string-typed attribute datasets
222/// (C++ `MAX_ATTRIBUTE_STRING_SIZE`).
223const MAX_ATTRIBUTE_STRING_SIZE: usize = 256;
224
225/// Element marker for a 256-byte fixed-length HDF5 string attribute dataset.
226///
227/// C `NDFileHDF5AttributeDataset.cpp:321-323` stores a string-valued
228/// NDAttribute as a rank-1 dataset whose element datatype is `H5T_C_S1` sized
229/// to `MAX_ATTRIBUTE_STRING_SIZE` bytes (null-terminated, ASCII). Implementing
230/// `H5Type` so `hdf5_type()` returns that fixed-length string datatype lets the
231/// high-level dataset builder emit `H5T_STR_NULLTERM` strings rather than a 2-D
232/// `H5T_STD_U8LE` byte array. The element is byte-identical to the raw 256-byte
233/// field already accumulated per frame, so the existing byte-oriented write
234/// path is reused unchanged.
235#[derive(Clone, Copy)]
236#[repr(transparent)]
237struct FixedStr256([u8; MAX_ATTRIBUTE_STRING_SIZE]);
238
239impl rust_hdf5::types::H5Type for FixedStr256 {
240    fn hdf5_type() -> rust_hdf5::format::messages::datatype::DatatypeMessage {
241        rust_hdf5::format::messages::datatype::DatatypeMessage::fixed_string(
242            MAX_ATTRIBUTE_STRING_SIZE as u32,
243        )
244    }
245
246    fn element_size() -> usize {
247        MAX_ATTRIBUTE_STRING_SIZE
248    }
249}
250
251/// Write state for one detector-source dataset (C `NDFileHDF5Dataset` held in
252/// `detDataMap`). Each `<dataset source="detector">` node gets its own live
253/// dataset handle, leading-axis frame counter, and partial chunk band so
254/// `detector_data_destination` routing can send frames to different datasets,
255/// each extending independently. The on-disk datatype/dataspace/frame shape are
256/// shared (set from the first frame) and live on `Hdf5Writer`.
257struct DetectorDataset {
258    /// Live dataset handle, retained across frames so the leading dimension can
259    /// be extended (`H5File::dataset` cannot re-open a dataset in write mode).
260    ds: rust_hdf5::H5Dataset,
261    /// Frames routed to THIS dataset — its leading-axis index and final extent.
262    frame_count: usize,
263    /// LE bytes of frames buffered for THIS dataset until a `nFramesChunks`-deep
264    /// chunk band fills. With one frame per chunk this holds at most one frame.
265    frame_band: Vec<Vec<u8>>,
266}
267
268/// Internal handle: either a standard H5File or a SWMR streaming writer.
269enum Hdf5Handle {
270    Standard {
271        file: H5File,
272        /// Detector image datasets keyed by C full name (leading slash) — the
273        /// C `detDataMap`. Created lazily on the first frame; one entry for the
274        /// common single-`<dataset source="detector">` layout.
275        detectors: std::collections::HashMap<String, DetectorDataset>,
276    },
277    Swmr {
278        // Boxed: `SwmrFileWriter` is much larger than the `Standard` variant.
279        writer: Box<SwmrFileWriter>,
280        ds_index: usize,
281        /// True only when a compression type was requested but no filter
282        /// pipeline could be built for it; false when compression is applied.
283        compression_dropped: bool,
284        /// `Some` when the streaming dataset is a fixed multi-extra-dimension
285        /// grid (`HDF5_nExtraDims >= 1`, uncompressed): frames are placed at
286        /// odometer positions via `write_chunk_at`. `None` for the common
287        /// single frame-axis case, which streams via `append_frame`.
288        grid: Option<SwmrGridLayout>,
289    },
290}
291
292/// Fixed multi-extra-dimension grid layout for the SWMR write path. The
293/// streaming dataset is a `create_grid_dataset` of full shape
294/// `[eds[N], …, eds[0], Y, X]`; each frame is written at its odometer chunk
295/// position with `SwmrFileWriter::write_chunk_at`, mirroring the standard
296/// path's [`Hdf5Writer::flush_band`]. Compressed multi-extra-dim stays
297/// collapsed (no compressed grid constructor; backend-blocked).
298struct SwmrGridLayout {
299    /// Fixed leading axes, outermost-first (`extra_dim_axes`).
300    leading: Vec<usize>,
301    /// HDF5-order frame dims `[Y, X]` (fastest-varying last).
302    frame_dims: Vec<usize>,
303    /// Full per-chunk shape `[1, …, 1, rc, cc]` (same rank as the dataset).
304    chunk: Vec<usize>,
305    /// Element size in bytes.
306    elem_size: usize,
307}
308
309/// HDF5 file writer using the rust-hdf5 crate.
310pub struct Hdf5Writer {
311    current_path: Option<PathBuf>,
312    handle: Option<Hdf5Handle>,
313    /// Total frames written to the file this open (C `nextRecord` across all
314    /// detector datasets): drives the create-on-first-frame gate, the
315    /// `frame_count()` accessor and the attribute-dataset row count. Per-dataset
316    /// leading-axis placement uses each [`DetectorDataset`]'s own `frame_count`.
317    frame_count: usize,
318    dataset_name: String,
319    /// Cached data type of the open primary dataset.
320    open_data_type: Option<NDDataType>,
321    /// Cached spatial (per-frame) dimensions, fastest-varying last.
322    open_frame_dims: Option<Vec<usize>>,
323    /// Codec of the open detector dataset(s) when they were created from a
324    /// pre-compressed first frame (direct chunk write path). `None` for an
325    /// uncompressed file. Mirrors C `NDFileHDF5Dataset::codec`, used by
326    /// `verifyChunking` to reject a mid-file codec change.
327    open_codec: Option<Codec>,
328    // compression
329    compression_type: i32,
330    z_compress_level: u32,
331    szip_num_pixels: u32,
332    nbit_precision: u32,
333    nbit_offset: u32,
334    jpeg_quality: u32,
335    blosc_shuffle_type: i32,
336    blosc_compressor: i32,
337    blosc_compress_level: u32,
338    // chunking & layout
339    chunk: ChunkConfig,
340    /// File write mode of the currently-open file (C `NDFileWriteMode`), and the
341    /// configured capture target (C `NDFileNumCapture`, pushed by the controller
342    /// before `open_file`). Both feed `attribute_chunking` — C's
343    /// `calculateAttributeChunking` resolves the auto (0) chunk from them.
344    open_mode: NDFileMode,
345    num_capture: usize,
346    n_extra_dims: usize,
347    extra_dims: [ExtraDim; MAX_EXTRA_DIMS],
348    fill_value: f64,
349    dim_att_datasets: bool,
350    // SWMR
351    swmr_mode: bool,
352    flush_nth_frame: usize,
353    pub swmr_cb_counter: u32,
354    // options
355    pub store_attributes: bool,
356    pub store_performance: bool,
357    pub total_runtime: f64,
358    pub total_bytes: u64,
359    /// Per-frame I/O timing rows for the `timestamp` performance dataset.
360    /// Each row is the 5 doubles C++ `writePerformanceDataset` records.
361    perf_rows: Vec<[f64; 5]>,
362    perf_prev: Option<std::time::Instant>,
363    perf_first: Option<std::time::Instant>,
364    /// Open NDAttribute time-series datasets, keyed by attribute name.
365    attr_datasets: Vec<AttributeDataset>,
366    /// Layout XML state.
367    layout_filename: Option<PathBuf>,
368    layout: Option<Hdf5Layout>,
369    pub layout_valid: bool,
370    pub layout_error: String,
371    /// Full path of the primary image dataset for the currently-open file.
372    /// `"data"` (flat root) when no valid layout is loaded; the layout's
373    /// `det_default` dataset path (e.g. `entry/instrument/detector/data`)
374    /// otherwise. Leading slash stripped — keyed as `rust-hdf5` keys datasets.
375    resolved_dataset_path: String,
376    /// Group prefix (no leading/trailing slash) for NDAttribute datasets.
377    /// Empty when flat; the layout `ndattr_default` group otherwise.
378    resolved_ndattr_group: String,
379    /// Group prefix for the performance dataset. Empty when flat.
380    resolved_perf_group: String,
381    /// Live NDAttribute values for the names referenced by layout
382    /// `<attribute source="ndattribute">` element-attrs (ADP-79). `first` is
383    /// the open-time frame (C `storeOnOpenAttributes` → OnFileOpen/OnFrame),
384    /// `last` the most recent frame (C `storeOnCloseAttributes` → OnFileClose).
385    /// Empty for the default layout, which declares no such element-attrs.
386    ndattr_first_values: std::collections::HashMap<String, NDAttrValue>,
387    ndattr_last_values: std::collections::HashMap<String, NDAttrValue>,
388    /// Distinct NDAttribute names referenced by element-attrs, captured at
389    /// open so per-frame updates need not re-walk the layout.
390    ndattr_element_names: Vec<String>,
391}
392
393impl Hdf5Writer {
394    pub fn new() -> Self {
395        Self {
396            current_path: None,
397            handle: None,
398            frame_count: 0,
399            dataset_name: "data".to_string(),
400            open_data_type: None,
401            open_frame_dims: None,
402            open_codec: None,
403            compression_type: 0,
404            z_compress_level: 6,
405            szip_num_pixels: 16,
406            // C default precision=8 bit, offset=0 (NDFileHDF5.cpp:2340-2341):
407            // a default-config N-bit request packs to 8 significant bits.
408            nbit_precision: 8,
409            nbit_offset: 0,
410            jpeg_quality: 90,
411            // C default bloscShuffleType=1 (byte shuffle), NDFileHDF5.cpp:2344.
412            blosc_shuffle_type: 1,
413            blosc_compressor: 0,
414            blosc_compress_level: 5,
415            chunk: ChunkConfig::default(),
416            open_mode: NDFileMode::Single,
417            num_capture: 1,
418            n_extra_dims: 0,
419            extra_dims: Default::default(),
420            fill_value: 0.0,
421            dim_att_datasets: false,
422            swmr_mode: false,
423            flush_nth_frame: 0,
424            swmr_cb_counter: 0,
425            store_attributes: true,
426            store_performance: false,
427            total_runtime: 0.0,
428            total_bytes: 0,
429            perf_rows: Vec::new(),
430            perf_prev: None,
431            perf_first: None,
432            attr_datasets: Vec::new(),
433            layout_filename: None,
434            // No user layout XML by default → C's built-in NeXus DEFAULT_LAYOUT.
435            layout: Some(Self::default_layout()),
436            layout_valid: false,
437            layout_error: String::new(),
438            resolved_dataset_path: "data".to_string(),
439            resolved_ndattr_group: String::new(),
440            resolved_perf_group: String::new(),
441            ndattr_first_values: std::collections::HashMap::new(),
442            ndattr_last_values: std::collections::HashMap::new(),
443            ndattr_element_names: Vec::new(),
444        }
445    }
446
447    pub fn set_dataset_name(&mut self, name: &str) {
448        self.dataset_name = name.to_string();
449    }
450
451    pub fn set_compression_type(&mut self, v: i32) {
452        self.compression_type = v;
453    }
454
455    pub fn set_z_compress_level(&mut self, v: u32) {
456        self.z_compress_level = v;
457    }
458
459    pub fn set_szip_num_pixels(&mut self, v: u32) {
460        self.szip_num_pixels = v;
461    }
462
463    pub fn set_blosc_shuffle_type(&mut self, v: i32) {
464        self.blosc_shuffle_type = v;
465    }
466
467    pub fn set_blosc_compressor(&mut self, v: i32) {
468        self.blosc_compressor = v;
469    }
470
471    pub fn set_blosc_compress_level(&mut self, v: u32) {
472        self.blosc_compress_level = v;
473    }
474
475    pub fn set_nbit_precision(&mut self, v: u32) {
476        self.nbit_precision = v;
477    }
478
479    pub fn set_nbit_offset(&mut self, v: u32) {
480        self.nbit_offset = v;
481    }
482
483    pub fn set_jpeg_quality(&mut self, v: u32) {
484        self.jpeg_quality = v;
485    }
486
487    pub fn set_store_attributes(&mut self, v: bool) {
488        self.store_attributes = v;
489    }
490
491    pub fn set_store_performance(&mut self, v: bool) {
492        self.store_performance = v;
493    }
494
495    pub fn set_swmr_mode(&mut self, v: bool) {
496        self.swmr_mode = v;
497    }
498
499    pub fn set_flush_nth_frame(&mut self, v: usize) {
500        self.flush_nth_frame = v;
501    }
502
503    pub fn set_chunk_size_auto(&mut self, v: bool) {
504        self.chunk.auto = v;
505    }
506
507    pub fn set_n_row_chunks(&mut self, v: usize) {
508        self.chunk.n_row_chunks = v;
509    }
510
511    pub fn set_n_col_chunks(&mut self, v: usize) {
512        self.chunk.n_col_chunks = v;
513    }
514
515    pub fn set_n_frames_chunks(&mut self, v: usize) {
516        self.chunk.n_frames_chunks = v;
517    }
518
519    pub fn set_ndattr_chunk(&mut self, v: usize) {
520        // `0` is the auto sentinel (C `HDF5_NDAttributeChunk` default); preserve
521        // it rather than clamping to 1, so `attribute_chunking` can resolve it.
522        self.chunk.ndattr_chunk = v;
523    }
524
525    pub fn set_n_extra_dims(&mut self, v: usize) {
526        // C `NDFileHDF5::writeInt32` rejects `HDF5_nExtraDims > MAXEXTRADIMS-1`
527        // (NDFileHDF5.cpp:1789). The dataset gains `nExtraDims+1` leading axes
528        // (the virtual scan dims plus the innermost frames-per-point axis), so
529        // axis i reads `extra_dims[nExtraDims - i]`; the highest index used is
530        // `nExtraDims`, which must stay within `extra_dims[0..MAX_EXTRA_DIMS]`.
531        self.n_extra_dims = v.min(MAX_EXTRA_DIMS - 1);
532    }
533
534    pub fn set_extra_dim_size(&mut self, idx: usize, size: usize) {
535        if idx < MAX_EXTRA_DIMS {
536            self.extra_dims[idx].size = size;
537        }
538    }
539
540    pub fn set_extra_dim_name(&mut self, idx: usize, name: &str) {
541        if idx < MAX_EXTRA_DIMS {
542            self.extra_dims[idx].name = name.to_string();
543        }
544    }
545
546    pub fn set_fill_value(&mut self, v: f64) {
547        self.fill_value = v;
548    }
549
550    pub fn set_dim_att_datasets(&mut self, v: bool) {
551        self.dim_att_datasets = v;
552    }
553
554    /// Parse the built-in [`DEFAULT_LAYOUT_XML`] into an `Hdf5Layout`. The
555    /// constant is known-good (covered by a unit test), so a parse failure is a
556    /// build bug, not a runtime condition.
557    fn default_layout() -> Hdf5Layout {
558        Hdf5Layout::parse(DEFAULT_LAYOUT_XML).expect("built-in default layout must parse")
559    }
560
561    /// Set the layout XML filename and (re)parse it. Returns whether parsing
562    /// succeeded; `layout_error` carries any message (C `HDF5_layoutErrorMsg`).
563    pub fn set_layout_filename(&mut self, path: &str) -> bool {
564        if path.trim().is_empty() {
565            // C loads the built-in DEFAULT_LAYOUT for an empty filename
566            // (NDFileHDF5.cpp:3896-3906); `layout_valid` tracks user-file
567            // validity, so it stays false here.
568            self.layout_filename = None;
569            self.layout = Some(Self::default_layout());
570            self.layout_valid = false;
571            self.layout_error.clear();
572            return true;
573        }
574        let p = PathBuf::from(path);
575        match Hdf5Layout::from_file(&p) {
576            Ok(layout) => {
577                self.layout_filename = Some(p);
578                self.layout = Some(layout);
579                self.layout_valid = true;
580                self.layout_error.clear();
581                true
582            }
583            Err(e) => {
584                self.layout_filename = Some(p);
585                self.layout = None;
586                self.layout_valid = false;
587                self.layout_error = e.0;
588                false
589            }
590        }
591    }
592
593    pub fn frame_count(&self) -> usize {
594        self.frame_count
595    }
596
597    /// Trigger a SWMR flush. No-op if not in SWMR mode.
598    pub fn flush_swmr(&mut self) {
599        if let Some(Hdf5Handle::Swmr { ref mut writer, .. }) = self.handle {
600            if writer.flush().is_ok() {
601                self.swmr_cb_counter += 1;
602            }
603        }
604    }
605
606    /// Returns true if SWMR is currently active.
607    pub fn is_swmr_active(&self) -> bool {
608        matches!(self.handle, Some(Hdf5Handle::Swmr { .. }))
609    }
610
611    /// Whether a requested SWMR compression type had no buildable pipeline.
612    pub fn swmr_compression_dropped(&self) -> bool {
613        matches!(
614            self.handle,
615            Some(Hdf5Handle::Swmr {
616                compression_dropped: true,
617                ..
618            })
619        )
620    }
621
622    /// Build a FilterPipeline from the current compression settings.
623    fn build_pipeline(&self, element_size: usize) -> Option<FilterPipeline> {
624        match self.compression_type {
625            COMPRESS_NONE => None,
626            COMPRESS_ZLIB => Some(FilterPipeline::deflate(self.z_compress_level)),
627            COMPRESS_SZIP => Some(FilterPipeline {
628                filters: vec![Filter {
629                    id: FILTER_SZIP,
630                    flags: 0,
631                    // C calls H5Pset_szip(cparms, H5_SZIP_NN_OPTION_MASK, szipNumPixels)
632                    // (NDFileHDF5.cpp:3372): nearest-neighbor coding (mask 32), not
633                    // entropy coding (mask 4). cd_values[1] is pixels_per_block; the
634                    // rust-hdf5 SZIP filter defaults bits_per_pixel/pixels_per_scanline.
635                    cd_values: vec![SZIP_NN_OPTION_MASK, self.szip_num_pixels],
636                }],
637            }),
638            COMPRESS_LZ4 => Some(FilterPipeline::lz4()),
639            COMPRESS_BSHUF => Some(FilterPipeline {
640                // Bitshuffle (HDF5 filter 32008): cd_values are
641                // [major_ver, minor_ver, elem_size, block_size, comp_type].
642                // comp_type 2 == LZ4, matching ADCore's default bitshuffle.
643                filters: vec![Filter {
644                    id: FILTER_BSHUF,
645                    flags: 0,
646                    cd_values: vec![0, 0, element_size as u32, 0, 2],
647                }],
648            }),
649            COMPRESS_BLOSC => {
650                let compressor_code = match self.blosc_compressor {
651                    BLOSC_LZ => 0,
652                    BLOSC_LZ4 => 1,
653                    BLOSC_LZ4HC => 2,
654                    BLOSC_SNAPPY => 3,
655                    BLOSC_ZLIB => 4,
656                    BLOSC_ZSTD => 5,
657                    _ => 0,
658                };
659                Some(FilterPipeline {
660                    filters: vec![Filter {
661                        id: FILTER_BLOSC,
662                        flags: 0,
663                        cd_values: vec![
664                            2,
665                            2,
666                            element_size as u32,
667                            0,
668                            self.blosc_compress_level,
669                            self.blosc_shuffle_type as u32,
670                            compressor_code,
671                        ],
672                    }],
673                })
674            }
675            COMPRESS_NBIT => {
676                // C (NDFileHDF5.cpp:3355-3357) applies N-bit compression by
677                // narrowing the dataset DATATYPE — H5Tset_precision /
678                // H5Tset_offset — and then registering a parameterless
679                // H5Pset_nbit filter; libhdf5's set_local callback derives the
680                // nbit `cd_values` parameter tree from that reduced-precision
681                // datatype. The filter therefore cannot be expressed by a
682                // pipeline alone: it must travel together with a reduced
683                // datatype override on the dataset builder. `build_pipeline` is
684                // shared with the SWMR streaming path, whose
685                // `create_streaming_dataset_chunked_compressed` hardcodes
686                // `T::hdf5_type()` and has no datatype-override hook — so N-bit
687                // is handled out of band by `nbit_packing` on the standard
688                // write path (which CAN override the datatype) and returns
689                // `None` here so SWMR keeps degrading N-bit to uncompressed
690                // instead of writing an nbit filter over a full-width datatype.
691                None
692            }
693            COMPRESS_JPEG => Some(FilterPipeline {
694                filters: vec![Filter {
695                    id: FILTER_JPEG,
696                    flags: 0,
697                    cd_values: vec![self.jpeg_quality],
698                }],
699            }),
700            _ => None,
701        }
702    }
703
704    /// N-bit packing for the standard write path: the reduced-precision on-disk
705    /// datatype override and the matching nbit filter, derived together.
706    ///
707    /// C narrows `this->datatype` via `H5Tset_precision`/`H5Tset_offset` and
708    /// then registers a parameterless `H5Pset_nbit` (NDFileHDF5.cpp:3355-3357);
709    /// libhdf5's `set_local` callback reads the reduced datatype to build the
710    /// filter `cd_values`. Here `DatasetBuilder::datatype` supplies the reduced
711    /// `FixedPoint` datatype and `FilterPipeline::nbit` builds the matching
712    /// `cd_values` tree `[nparms, need_not_compress, d_nelmts, NBIT_ATOMIC,
713    /// size, order, precision, offset]`, so the dataset is byte-readable by
714    /// h5py / libhdf5.
715    ///
716    /// Returns `Some((datatype, pipeline))` only for `COMPRESS_NBIT` over an
717    /// integer (FixedPoint) element type. Float element types do not map to a
718    /// reduced-precision fixed-point datatype (N-bit over floats is not an
719    /// areaDetector use case), and an out-of-range precision (0, which HDF5's
720    /// `H5Tset_precision` rejects) cannot pack — both return `None`, leaving the
721    /// frame on the lossless uncompressed path.
722    ///
723    /// `chunk_nelmts` is the element count of one on-disk chunk (= `d_nelmts`);
724    /// `flush_band` always writes a full, zero-padded chunk, so every filtered
725    /// write supplies exactly `chunk_nelmts * element_size` bytes — the size
726    /// `apply_nbit` requires.
727    fn nbit_packing(
728        &self,
729        data_type: NDDataType,
730        chunk_nelmts: usize,
731    ) -> Option<(DatatypeMessage, FilterPipeline)> {
732        if self.compression_type != COMPRESS_NBIT {
733            return None;
734        }
735        let (size, signed) = match data_type {
736            NDDataType::Int8 => (1u32, true),
737            NDDataType::UInt8 => (1, false),
738            NDDataType::Int16 => (2, true),
739            NDDataType::UInt16 => (2, false),
740            NDDataType::Int32 => (4, true),
741            NDDataType::UInt32 => (4, false),
742            NDDataType::Int64 => (8, true),
743            NDDataType::UInt64 => (8, false),
744            NDDataType::Float32 | NDDataType::Float64 => return None,
745        };
746        if self.nbit_precision == 0 || self.nbit_precision > size * 8 {
747            return None;
748        }
749        let dt = DatatypeMessage::FixedPoint {
750            size,
751            byte_order: ByteOrder::LittleEndian,
752            signed,
753            bit_offset: self.nbit_offset as u16,
754            bit_precision: self.nbit_precision as u16,
755        };
756        let pipeline = FilterPipeline::nbit(&dt, chunk_nelmts);
757        Some((dt, pipeline))
758    }
759
760    /// Build the HDF5 filter pipeline that matches a **pre-compressed** input
761    /// array's codec, so direct chunk write records the filter the compressed
762    /// bytes were produced with. Mirrors C `NDFileHDF5::configureCompression`
763    /// (NDFileHDF5.cpp:3314-3331), which overrides the writer's own compression
764    /// settings from `pArray->codec`. Returns `None` (caller rejects the frame,
765    /// as C `verifyChunking` does) for any codec C does not direct-chunk-write:
766    /// the only `NDCODEC` values C handles are JPEG, BLOSC, LZ4 and BSLZ4
767    /// (Codec.h:12-18); the Rust-only `Zlib`/`LZ4HDF5`/`None` have no C analog.
768    ///
769    /// `element_size` is the size of one **uncompressed** element (the codec's
770    /// `original_data_type`), which the BLOSC/bitshuffle filters record as the
771    /// type size.
772    fn codec_filter_pipeline(&self, codec: &Codec) -> Option<FilterPipeline> {
773        let element_size = codec.original_data_type.element_size() as u32;
774        match codec.name {
775            CodecName::LZ4 => Some(FilterPipeline::lz4()),
776            CodecName::BSLZ4 => Some(FilterPipeline {
777                // Bitshuffle (HDF5 filter 32008), matching `build_pipeline`'s
778                // BSHUF arm: [major, minor, elem_size, block_size(0=auto),
779                // comp_type=2 (LZ4)].
780                filters: vec![Filter {
781                    id: FILTER_BSHUF,
782                    flags: 0,
783                    cd_values: vec![0, 0, element_size, 0, 2],
784                }],
785            }),
786            CodecName::Blosc => {
787                // C copies the array's own blosc params (level/shuffle/
788                // compressor) into the dataset filter (configureCompression
789                // NDFileHDF5.cpp:3320-3323), not the writer's defaults.
790                Some(FilterPipeline {
791                    filters: vec![Filter {
792                        id: FILTER_BLOSC,
793                        flags: 0,
794                        cd_values: vec![
795                            2,
796                            2,
797                            element_size,
798                            0,
799                            codec.level.max(0) as u32,
800                            codec.shuffle.max(0) as u32,
801                            codec.compressor.max(0) as u32,
802                        ],
803                    }],
804                })
805            }
806            // C `configureCompression` does not copy a JPEG quality from the
807            // array; the dataset's JPEG filter carries the writer's configured
808            // `HDF5_jpegQuality`. Quality is a compression-side parameter, unused
809            // when the reader reverses the filter, so direct-chunk-write parity
810            // is unaffected by its exact value.
811            CodecName::JPEG => Some(FilterPipeline {
812                filters: vec![Filter {
813                    id: FILTER_JPEG,
814                    flags: 0,
815                    cd_values: vec![self.jpeg_quality],
816                }],
817            }),
818            CodecName::None | CodecName::Zlib | CodecName::LZ4HDF5 => None,
819        }
820    }
821
822    /// Chunk dims for the frame (image) axes only — no leading dims.
823    ///
824    /// For a 2-D frame these are the row/column chunk sizes
825    /// (`HDF5_nRowChunks` / `HDF5_nColChunks`; 0, auto, or out-of-range → the
826    /// full dimension, matching C++ `NDFileHDF5` chunk-size selection). Other
827    /// ranks get one full per-frame tile (no sub-tiling).
828    fn frame_chunk_dims(&self, frame_dims: &[usize]) -> Vec<usize> {
829        if frame_dims.len() == 2 {
830            let y = frame_dims[0].max(1);
831            let x = frame_dims[1].max(1);
832            vec![
833                Self::clamp_chunk(self.chunk.n_row_chunks, y, self.chunk.auto),
834                Self::clamp_chunk(self.chunk.n_col_chunks, x, self.chunk.auto),
835            ]
836        } else {
837            frame_dims.iter().map(|&d| d.max(1)).collect()
838        }
839    }
840
841    /// The fixed leading extra-dimension axes, outermost-first, for the
842    /// standard (non-SWMR) write path; `None` when `HDF5_nExtraDims == 0`.
843    ///
844    /// `HDF5_nExtraDims = N` produces `N+1` leading axes: the `N` virtual scan
845    /// dimensions plus the innermost frames-per-point axis. C builds them in
846    /// reverse param order (NDFileHDF5.cpp:3182-3215, doc order
847    /// `{Nth virtual, …, Y, X, frames-per-point, frame Y, frame X}`): axis `i`
848    /// uses `extraDimSize[N - i]`, so outermost is `extraDimSize[N]` and the
849    /// innermost leading axis is `extraDimSize[0]` ("N", frames per point).
850    fn extra_dim_axes(&self) -> Option<Vec<usize>> {
851        if self.n_extra_dims == 0 {
852            return None;
853        }
854        Some(
855            (0..=self.n_extra_dims)
856                .rev()
857                .map(|i| self.extra_dims[i].size.max(1))
858                .collect(),
859        )
860    }
861
862    /// Standard (non-SWMR) dataset shape and chunk geometry for the primary
863    /// image dataset, faithful to C `NDFileHDF5::configureDims`.
864    ///
865    /// Layout, fastest-varying last. With no extra dims it is `[frame, Y, X]`
866    /// and the single leading axis is extensible. With `HDF5_nExtraDims = N`
867    /// it is `[eds[N], …, eds[1], eds[0], Y, X]` — `N+1` fixed leading axes
868    /// created at full configured size, each chunked at 1 (the frame data is
869    /// row-major identical to the collapsed form, only the dataspace rank
870    /// differs). `close_file` calls `set_extent` to trim any frame-axis
871    /// chunk-rounding back to the exact frame shape.
872    ///
873    /// Returns `(shape, chunk, leading)` where `leading` is `Some([eds…])`
874    /// (the fixed leading axes, outermost-first) when extra dims fix the
875    /// dataset up front, or `None` when the single leading frame axis is
876    /// extended per write.
877    fn standard_layout(
878        &self,
879        frame_dims: &[usize],
880    ) -> (Vec<usize>, Vec<usize>, Option<Vec<usize>>) {
881        let frame_chunk = self.frame_chunk_dims(frame_dims);
882        match self.extra_dim_axes() {
883            Some(leading) => {
884                let mut shape = leading.clone();
885                shape.extend_from_slice(frame_dims);
886                // Each leading axis is chunked at 1 (extraDimChunk defaults to
887                // 1; per-axis chunking is not plumbed in this port).
888                let mut chunk = vec![1usize; leading.len()];
889                chunk.extend_from_slice(&frame_chunk);
890                (shape, chunk, Some(leading))
891            }
892            None => {
893                let mut shape = vec![1usize];
894                shape.extend_from_slice(frame_dims);
895                let mut chunk = vec![self.chunk.n_frames_chunks.max(1)];
896                chunk.extend_from_slice(&frame_chunk);
897                (shape, chunk, None)
898            }
899        }
900    }
901
902    /// Dataset shape and chunk geometry for a pre-compressed (direct chunk
903    /// write) detector dataset. Identical leading-axis structure to
904    /// [`standard_layout`], but every chunk holds exactly **one whole frame**:
905    /// the codec compressed each frame as a single unit, so C
906    /// `NDFileHDF5Dataset::verifyChunking` (NDFileHDF5Dataset.cpp:185-235)
907    /// requires the leading frame-axis chunk == 1 and every frame-axis chunk ==
908    /// the full frame dimension (no row/column sub-tiling). A pre-compressed
909    /// chunk cannot be split, so `HDF5_nRowChunks`/`nColChunks`/`nFramesChunks`
910    /// do not apply here.
911    fn compressed_layout(
912        &self,
913        frame_dims: &[usize],
914    ) -> (Vec<usize>, Vec<usize>, Option<Vec<usize>>) {
915        let frame_chunk: Vec<usize> = frame_dims.iter().map(|&d| d.max(1)).collect();
916        match self.extra_dim_axes() {
917            Some(leading) => {
918                let mut shape = leading.clone();
919                shape.extend_from_slice(frame_dims);
920                let mut chunk = vec![1usize; leading.len()];
921                chunk.extend_from_slice(&frame_chunk);
922                (shape, chunk, Some(leading))
923            }
924            None => {
925                let mut shape = vec![1usize];
926                shape.extend_from_slice(frame_dims);
927                let mut chunk = vec![1usize];
928                chunk.extend_from_slice(&frame_chunk);
929                (shape, chunk, None)
930            }
931        }
932    }
933
934    /// Collapsed single-leading-axis layout used by the SWMR streaming path,
935    /// whose backend (`SwmrFileWriter`) supports only one extensible frame
936    /// axis. With extra dims the leading axis is fixed at the product of the
937    /// extra-dim sizes (the N-dimensional structure is recorded as HDF5
938    /// attributes); the non-SWMR path uses [`standard_layout`], which builds
939    /// C's full multi-extra-dimension dataspace.
940    ///
941    /// Returns `(shape, chunk, extra_dim_extent)` where `extra_dim_extent` is
942    /// `Some(total_frames)` when extra dims fix the dataset size up front, or
943    /// `None` when the leading frame axis is extended per write.
944    fn primary_layout(&self, frame_dims: &[usize]) -> (Vec<usize>, Vec<usize>, Option<usize>) {
945        let extra_extent = if self.n_extra_dims > 0 {
946            Some(
947                (0..self.n_extra_dims)
948                    .map(|i| self.extra_dims[i].size.max(1))
949                    .product::<usize>(),
950            )
951        } else {
952            None
953        };
954
955        let mut shape: Vec<usize> = vec![extra_extent.unwrap_or(1)];
956        shape.extend_from_slice(frame_dims);
957
958        let mut chunk = vec![if extra_extent.is_some() {
959            1
960        } else {
961            self.chunk.n_frames_chunks.max(1)
962        }];
963        chunk.extend_from_slice(&self.frame_chunk_dims(frame_dims));
964        (shape, chunk, extra_extent)
965    }
966
967    /// Row-major unravel of a flat frame index into per-axis coordinates over
968    /// `dims` (outermost first; the innermost axis varies fastest). This is C's
969    /// extra-dimension odometer — `NDFileHDF5Dataset::extendDataSet`
970    /// (NDFileHDF5Dataset.cpp:137-157) increments the innermost axis first and
971    /// carries outward.
972    fn unravel(mut idx: usize, dims: &[usize]) -> Vec<usize> {
973        let mut coords = vec![0usize; dims.len()];
974        for d in (0..dims.len()).rev() {
975            let s = dims[d].max(1);
976            coords[d] = idx % s;
977            idx /= s;
978        }
979        coords
980    }
981
982    /// C++ `NDFileHDF5` chunk-size rule: 0, auto, or a value larger than the
983    /// dimension means "chunk the whole dimension"; otherwise the user value.
984    fn clamp_chunk(requested: usize, dim: usize, auto: bool) -> usize {
985        if auto || requested == 0 || requested > dim {
986            dim
987        } else {
988            requested
989        }
990    }
991
992    /// Write one chunk band of `chunk[0]` consecutive frames into the primary
993    /// dataset at the given `leading_coords` (the chunk-grid coordinates of the
994    /// leading axes, outermost-first).
995    ///
996    /// For the extensible single-axis layout `leading_coords` is `[band_idx]`
997    /// and up to `fc = chunk[0]` frames stack along that axis. For the fixed
998    /// multi-extra-dimension layout `leading_coords` is the full
999    /// `[eds[N], …, eds[0]]` odometer position (each leading chunk is 1, so
1000    /// `fc == 1` and one frame is written per call).
1001    ///
1002    /// The frame is split into `ceil(Y/rc) x ceil(X/cc)` chunk tiles, each
1003    /// written with `write_chunk_at(leading_coords ++ [row_tile, col_tile], ..)`.
1004    /// Tiles are `[fc, rc, cc]`; edge tiles and a partial final band (fewer
1005    /// than `fc` frames) are zero-padded. `close_file`'s `set_extent` trims
1006    /// the resulting over-extension back to the exact frame shape.
1007    fn flush_band(
1008        ds: &rust_hdf5::H5Dataset,
1009        leading_coords: &[usize],
1010        frames: &[Vec<u8>],
1011        frame_dims: &[usize],
1012        chunk: &[usize],
1013        elem_size: usize,
1014    ) -> ADResult<()> {
1015        for (coords, buf) in
1016            Self::band_chunk_writes(leading_coords, frames, frame_dims, chunk, elem_size)
1017        {
1018            ds.write_chunk_at(&coords, &buf).map_err(|e| {
1019                ADError::UnsupportedConversion(format!("HDF5 write_chunk_at error: {}", e))
1020            })?;
1021        }
1022        Ok(())
1023    }
1024
1025    /// Compute the `(chunk_coords, chunk_bytes)` writes for one band of `frames`
1026    /// at `leading_coords`. The single source of tile math shared by the
1027    /// standard path's [`flush_band`](Self::flush_band) (writes each via
1028    /// `H5Dataset::write_chunk_at`) and the SWMR grid path
1029    /// ([`write_swmr`](Self::write_swmr), via `SwmrFileWriter::write_chunk_at`).
1030    ///
1031    /// `chunk` is the full per-chunk shape `[fc, frame_chunk…]`; `fc = chunk[0]`
1032    /// frames stack along the first leading axis. A 2-D frame is split into
1033    /// `ceil(Y/rc) x ceil(X/cc)` tiles `[fc, rc, cc]`; a non-2-D frame is one
1034    /// chunk per band. Edge tiles and a partial final band (fewer than `fc`
1035    /// frames) are zero-padded. Coords are in chunk units, row-major:
1036    /// `leading_coords ++ [row_tile, col_tile]` (or `++ [0; frame_rank]`).
1037    fn band_chunk_writes(
1038        leading_coords: &[usize],
1039        frames: &[Vec<u8>],
1040        frame_dims: &[usize],
1041        chunk: &[usize],
1042        elem_size: usize,
1043    ) -> Vec<(Vec<usize>, Vec<u8>)> {
1044        let lead = leading_coords.len();
1045        let fc = chunk[0];
1046        // Non-2-D frame: one chunk per band, frames stacked along the first
1047        // leading axis (a partial band leaves trailing frames zero).
1048        if frame_dims.len() != 2 {
1049            let frame_len = frame_dims.iter().product::<usize>() * elem_size;
1050            let mut buf = vec![0u8; fc * frame_len];
1051            for (f, fb) in frames.iter().take(fc).enumerate() {
1052                buf[f * frame_len..f * frame_len + frame_len].copy_from_slice(fb);
1053            }
1054            let mut coords = leading_coords.to_vec();
1055            coords.extend(std::iter::repeat_n(0, frame_dims.len()));
1056            return vec![(coords, buf)];
1057        }
1058
1059        let (y, x) = (frame_dims[0], frame_dims[1]);
1060        let (rc, cc) = (chunk[lead], chunk[lead + 1]);
1061        let row_tiles = y.div_ceil(rc);
1062        let col_tiles = x.div_ceil(cc);
1063        let mut writes = Vec::with_capacity(row_tiles * col_tiles);
1064        for ry in 0..row_tiles {
1065            for cx in 0..col_tiles {
1066                let mut tile = vec![0u8; fc * rc * cc * elem_size];
1067                for f in 0..fc {
1068                    let Some(fb) = frames.get(f) else {
1069                        break; // partial band: trailing frames stay zero
1070                    };
1071                    for r in 0..rc {
1072                        let sy = ry * rc + r;
1073                        if sy >= y {
1074                            break;
1075                        }
1076                        for c in 0..cc {
1077                            let sx = cx * cc + c;
1078                            if sx >= x {
1079                                break;
1080                            }
1081                            let src = (sy * x + sx) * elem_size;
1082                            let dst = ((f * rc + r) * cc + c) * elem_size;
1083                            tile[dst..dst + elem_size].copy_from_slice(&fb[src..src + elem_size]);
1084                        }
1085                    }
1086                }
1087                let mut coords = leading_coords.to_vec();
1088                coords.push(ry);
1089                coords.push(cx);
1090                writes.push((coords, tile));
1091            }
1092        }
1093        writes
1094    }
1095
1096    /// Flush each detector dataset's partial frame band and trim its logical
1097    /// extent to its own frame count. Called from `close_file`. Every dataset in
1098    /// `detDataMap` is finalised independently, each at the count of frames the
1099    /// `detector_data_destination` routing sent it (C closes every
1100    /// `NDFileHDF5Dataset` it created, not just the default).
1101    fn finalize_standard_datasets(&mut self) -> ADResult<()> {
1102        let Some(frame_dims) = self.open_frame_dims.clone() else {
1103            return Ok(());
1104        };
1105        let (_, chunk, leading) = self.standard_layout(&frame_dims);
1106        let elem_size = self.open_data_type.map(|t| t.element_size()).unwrap_or(1);
1107        let fc = chunk[0];
1108        let Some(Hdf5Handle::Standard { detectors, .. }) = self.handle.as_mut() else {
1109            return Ok(());
1110        };
1111        for det in detectors.values_mut() {
1112            let total = det.frame_count;
1113            // A partial band only survives the extensible single-axis layout
1114            // (the fixed multi-dim layout writes one frame per call, `fc == 1`).
1115            if !det.frame_band.is_empty() {
1116                let last = total.saturating_sub(1);
1117                let leading_coords = match &leading {
1118                    Some(lead) => Self::unravel(last, lead),
1119                    None => vec![last / fc],
1120                };
1121                Self::flush_band(
1122                    &det.ds,
1123                    &leading_coords,
1124                    &det.frame_band,
1125                    &frame_dims,
1126                    &chunk,
1127                    elem_size,
1128                )?;
1129                det.frame_band.clear();
1130            }
1131            // Trim the logical extent: write_chunk_at rounds dims up to chunk
1132            // boundaries; set_extent restores the exact frame shape. The
1133            // extensible axis trims to `total`; the fixed leading axes are
1134            // already exact and only the frame axes may have over-extended.
1135            if total > 0 {
1136                let mut dims = leading.clone().unwrap_or_else(|| vec![total]);
1137                dims.extend_from_slice(&frame_dims);
1138                det.ds.set_extent(&dims).map_err(|e| {
1139                    ADError::UnsupportedConversion(format!("HDF5 set_extent error: {}", e))
1140                })?;
1141            }
1142        }
1143        Ok(())
1144    }
1145
1146    /// Open file in SWMR streaming mode.
1147    ///
1148    /// Ordering mirrors C `NDFileHDF5::openFile` (`NDFileHDF5.cpp:264`-`335`):
1149    /// the file layout tree and datasets are created, then `createHardLinks`
1150    /// (`NDFileHDF5.cpp:320`-`321`) runs, and only then `startSWMR`
1151    /// (`NDFileHDF5.cpp:324`-`326`). The new rust-hdf5 0.2.17 `SwmrFileWriter`
1152    /// exposes `create_group` / `assign_dataset_to_group` / `create_hard_link`
1153    /// callable before `start_swmr()`; a group or link created before
1154    /// `start_swmr()` is visible to SWMR readers for the whole streaming
1155    /// window. So here the image dataset is placed at the layout's nested
1156    /// `resolved_dataset_path` and the layout `<hardlink>` elements are
1157    /// materialised before SWMR mode is entered — not on the close path.
1158    fn open_swmr(&mut self, path: &Path, array: &NDArray) -> ADResult<()> {
1159        let mut swmr = SwmrFileWriter::create(path)
1160            .map_err(|e| ADError::UnsupportedConversion(format!("SWMR create error: {}", e)))?;
1161
1162        let usize_frame_dims: Vec<usize> = array.dims.iter().rev().map(|d| d.size).collect();
1163        let frame_dims: Vec<u64> = usize_frame_dims.iter().map(|&d| d as u64).collect();
1164
1165        // Full chunk geometry, `[fc, rc, cc]`: HDF5_nFramesChunks deep and
1166        // the row/column tile sizes. rust-hdf5 0.2.15
1167        // `create_streaming_dataset_chunked` band-buffers whole frames and
1168        // zero-pads the final partial band at close, keeping the logical
1169        // frame count exact.
1170        let element_size = array.data.data_type().element_size();
1171        let pipeline = self.build_pipeline(element_size);
1172        let chunk: Vec<u64> = {
1173            let (_, c, _) = self.primary_layout(&usize_frame_dims);
1174            c.iter().map(|&v| v as u64).collect()
1175        };
1176
1177        // With `HDF5_nExtraDims >= 1` and no compression, mirror the standard
1178        // path's full multi-extra-dimension dataspace: a fixed grid of shape
1179        // `[eds[N], …, eds[0], Y, X]` filled at odometer positions via
1180        // `write_chunk_at`. Compressed multi-extra-dim has no grid constructor
1181        // and stays collapsed to the single-axis streaming layout above.
1182        let (grid_shape_usize, grid_chunk_usize, grid_leading) =
1183            self.standard_layout(&usize_frame_dims);
1184        let use_grid = grid_leading.is_some() && pipeline.is_none();
1185        let grid_shape: Vec<u64> = grid_shape_usize.iter().map(|&v| v as u64).collect();
1186        let grid_chunk: Vec<u64> = grid_chunk_usize.iter().map(|&v| v as u64).collect();
1187
1188        // The streaming dataset is created with its full nested layout path
1189        // as the dataset name (default flat `data` without a layout). The
1190        // `SwmrFileWriter` emits a path-named dataset that is also assigned to
1191        // a group under that group with just the leaf, while keeping the full
1192        // name addressable so a layout `<hardlink target="/entry/.../data">`
1193        // resolves against it. `ds_group_path` is the parent group the
1194        // dataset is re-parented into via `assign_dataset_to_group` below.
1195        let ds_group_path: Option<String> = self
1196            .resolved_dataset_path
1197            .rsplit_once('/')
1198            .map(|(group_path, _leaf)| group_path.to_string());
1199        let ds_name = self.resolved_dataset_path.clone();
1200
1201        macro_rules! create_ds {
1202            ($t:ty) => {
1203                if use_grid {
1204                    swmr.create_grid_dataset::<$t>(&ds_name, &grid_shape, &grid_chunk)
1205                        .map_err(|e| {
1206                            ADError::UnsupportedConversion(format!(
1207                                "SWMR create grid dataset error: {}",
1208                                e
1209                            ))
1210                        })
1211                } else {
1212                    match pipeline.clone() {
1213                        Some(pl) => swmr
1214                            .create_streaming_dataset_chunked_compressed::<$t>(
1215                                &ds_name,
1216                                &frame_dims,
1217                                &chunk,
1218                                pl,
1219                            )
1220                            .map_err(|e| {
1221                                ADError::UnsupportedConversion(format!(
1222                                    "SWMR create compressed dataset error: {}",
1223                                    e
1224                                ))
1225                            }),
1226                        None => swmr
1227                            .create_streaming_dataset_chunked::<$t>(&ds_name, &frame_dims, &chunk)
1228                            .map_err(|e| {
1229                                ADError::UnsupportedConversion(format!(
1230                                    "SWMR create dataset error: {}",
1231                                    e
1232                                ))
1233                            }),
1234                    }
1235                }
1236            };
1237        }
1238
1239        let ds_index = match array.data.data_type() {
1240            NDDataType::Int8 => create_ds!(i8)?,
1241            NDDataType::UInt8 => create_ds!(u8)?,
1242            NDDataType::Int16 => create_ds!(i16)?,
1243            NDDataType::UInt16 => create_ds!(u16)?,
1244            NDDataType::Int32 => create_ds!(i32)?,
1245            NDDataType::UInt32 => create_ds!(u32)?,
1246            NDDataType::Int64 => create_ds!(i64)?,
1247            NDDataType::UInt64 => create_ds!(u64)?,
1248            NDDataType::Float32 => create_ds!(f32)?,
1249            NDDataType::Float64 => create_ds!(f64)?,
1250        };
1251
1252        // Build the layout group tree, place the image dataset inside its
1253        // nested layout group, materialise its constant attributes and the
1254        // layout `<hardlink>` elements — all BEFORE `start_swmr()` so SWMR
1255        // readers see the nested paths and aliases for the whole streaming
1256        // window. C `NDFileHDF5.cpp:320`-`326`: `createHardLinks` then
1257        // `startSWMR`.
1258        self.build_swmr_layout_groups(&mut swmr)?;
1259        if let Some(ref group_path) = ds_group_path {
1260            // `SwmrFileWriter` keys groups by their absolute path (leading
1261            // `/`); `resolved_dataset_path` is stored stripped, so re-add it.
1262            let abs_group = format!("/{}", group_path);
1263            swmr.assign_dataset_to_group(&abs_group, ds_index)
1264                .map_err(|e| {
1265                    ADError::UnsupportedConversion(format!(
1266                        "SWMR assign dataset to group '{}': {}",
1267                        abs_group, e
1268                    ))
1269                })?;
1270        }
1271        self.write_swmr_layout_dataset_attrs(&mut swmr, ds_index)?;
1272        self.write_swmr_ndarray_default_attrs(&mut swmr, ds_index, array)?;
1273        self.build_swmr_layout_hardlinks(&mut swmr)?;
1274        // Open-time `<attribute source="ndattribute">` group and dataset
1275        // element-attrs must be created before the SWMR lock; close-time ones
1276        // cannot (HDF5 forbids attribute creation after the lock).
1277        self.write_swmr_ndattr_element_attrs(&mut swmr, ds_index)?;
1278
1279        swmr.start_swmr()
1280            .map_err(|e| ADError::UnsupportedConversion(format!("SWMR start error: {}", e)))?;
1281
1282        // Compression is applied to SWMR datasets via the filter pipeline
1283        // above. `compression_dropped` is only set when a compression type was
1284        // requested but no pipeline could be built for it (an unsupported
1285        // compressor) — never a silent drop.
1286        let compression_dropped = self.compression_type != COMPRESS_NONE && pipeline.is_none();
1287        if compression_dropped {
1288            eprintln!(
1289                "NDFileHDF5: WARNING — SWMR mode requested compression type {} \
1290                 but no filter pipeline could be built for it; the SWMR file \
1291                 will be written UNCOMPRESSED.",
1292                self.compression_type
1293            );
1294        }
1295
1296        let grid = if use_grid {
1297            // `use_grid` implies `grid_leading.is_some()`.
1298            grid_leading.map(|leading| SwmrGridLayout {
1299                leading,
1300                frame_dims: usize_frame_dims.clone(),
1301                chunk: grid_chunk_usize.clone(),
1302                elem_size: element_size,
1303            })
1304        } else {
1305            None
1306        };
1307        self.handle = Some(Hdf5Handle::Swmr {
1308            writer: Box::new(swmr),
1309            ds_index,
1310            compression_dropped,
1311            grid,
1312        });
1313        self.open_data_type = Some(array.data.data_type());
1314        self.open_frame_dims = Some(array.dims.iter().rev().map(|d| d.size).collect::<Vec<_>>());
1315        Ok(())
1316    }
1317
1318    /// Build every group node declared in the loaded layout XML against a
1319    /// `SwmrFileWriter`, the SWMR counterpart of [`build_layout_groups`].
1320    ///
1321    /// Paths are created parent-first (shortest path-depth first) via the
1322    /// rust-hdf5 0.2.17 `SwmrFileWriter::create_group` API, which takes the
1323    /// parent group path and a leaf name. Called from `open_swmr` before
1324    /// `start_swmr()` so the groups are visible to SWMR readers for the whole
1325    /// streaming window. No-op when no layout is loaded.
1326    fn build_swmr_layout_groups(&self, swmr: &mut SwmrFileWriter) -> ADResult<()> {
1327        let layout = match self.layout.as_ref() {
1328            Some(l) => l,
1329            None => return Ok(()),
1330        };
1331        fn collect<'a>(
1332            g: &'a crate::hdf5_layout::LayoutGroup,
1333            prefix: &str,
1334            out: &mut Vec<(String, &'a crate::hdf5_layout::LayoutGroup)>,
1335        ) {
1336            let here = if prefix.is_empty() {
1337                g.name.clone()
1338            } else {
1339                format!("{}/{}", prefix, g.name)
1340            };
1341            out.push((here.clone(), g));
1342            for sub in &g.groups {
1343                collect(sub, &here, out);
1344            }
1345        }
1346        let mut nodes = Vec::new();
1347        for g in &layout.groups {
1348            collect(g, "", &mut nodes);
1349        }
1350        nodes.sort_by_key(|(p, _)| p.matches('/').count());
1351        let mut created: std::collections::HashSet<String> = std::collections::HashSet::new();
1352        for (path, node) in &nodes {
1353            if !created.insert(path.clone()) {
1354                continue;
1355            }
1356            let (parent, leaf) = match path.rsplit_once('/') {
1357                Some((p, l)) => (format!("/{}", p), l),
1358                None => ("/".to_string(), path.as_str()),
1359            };
1360            swmr.create_group(&parent, leaf).map_err(|e| {
1361                ADError::UnsupportedConversion(format!("SWMR layout group '{}': {}", path, e))
1362            })?;
1363            // C attaches the group's constant <attribute> nodes (NX_class) via
1364            // writeHdfAttributes (NDFileHDF5.cpp:693-695); the absolute group
1365            // path is `/<path>`.
1366            write_swmr_group_constant_attrs(swmr, &format!("/{}", path), &node.attributes)?;
1367        }
1368        Ok(())
1369    }
1370
1371    /// Materialise every `<hardlink>` declared in the loaded layout XML against
1372    /// a `SwmrFileWriter`, the SWMR counterpart of [`build_layout_hardlinks`].
1373    ///
1374    /// Uses the rust-hdf5 0.2.17 `SwmrFileWriter::create_hard_link` API. Called
1375    /// from `open_swmr` after the layout groups and image dataset exist and
1376    /// before `start_swmr()` — matching C `NDFileHDF5.cpp:320`-`321`
1377    /// `createHardLinks`, which runs before `startSWMR`. A link created before
1378    /// `start_swmr()` is visible to SWMR readers for the whole streaming
1379    /// window. No-op when no layout is loaded.
1380    fn build_swmr_layout_hardlinks(&self, swmr: &mut SwmrFileWriter) -> ADResult<()> {
1381        let layout = match self.layout.as_ref() {
1382            Some(l) => l,
1383            None => return Ok(()),
1384        };
1385        fn collect<'a>(
1386            g: &'a crate::hdf5_layout::LayoutGroup,
1387            prefix: &str,
1388            out: &mut Vec<(String, &'a crate::hdf5_layout::LayoutHardlink)>,
1389        ) {
1390            let here = if prefix.is_empty() {
1391                g.name.clone()
1392            } else {
1393                format!("{}/{}", prefix, g.name)
1394            };
1395            for hl in &g.hardlinks {
1396                out.push((here.clone(), hl));
1397            }
1398            for sub in &g.groups {
1399                collect(sub, &here, out);
1400            }
1401        }
1402        let mut links = Vec::new();
1403        for g in &layout.groups {
1404            collect(g, "", &mut links);
1405        }
1406        for (parent_path, hl) in &links {
1407            let parent = format!("/{}", parent_path);
1408            swmr.create_hard_link(&parent, &hl.name, &hl.target)
1409                .map_err(|e| {
1410                    ADError::UnsupportedConversion(format!(
1411                        "SWMR layout hardlink '{}/{}' -> '{}': {}",
1412                        parent_path, hl.name, hl.target, e
1413                    ))
1414                })?;
1415        }
1416        Ok(())
1417    }
1418
1419    /// Materialise the loaded layout XML's `constant` HDF5 attributes attached
1420    /// to the primary image dataset against a `SwmrFileWriter`. This mirrors
1421    /// the standard close path's `layout_ds_attrs` block in
1422    /// `create_primary_dataset` (e.g. the NeXus `signal=1` marker). Only
1423    /// `constant`-sourced attributes are materialised; `ndattribute`-sourced
1424    /// nodes carry per-frame values and are out of scope here. No-op when no
1425    /// layout is loaded.
1426    fn write_swmr_layout_dataset_attrs(
1427        &self,
1428        swmr: &mut SwmrFileWriter,
1429        ds_index: usize,
1430    ) -> ADResult<()> {
1431        use crate::hdf5_layout::{LayoutDataType, LayoutSource};
1432        let layout = match self.layout.as_ref() {
1433            Some(l) => l,
1434            None => return Ok(()),
1435        };
1436        let resolved_ds = self.resolved_dataset_path.as_str();
1437        let mut attrs: Vec<(String, LayoutDataType, String)> = Vec::new();
1438        layout.for_each_dataset(|path, d| {
1439            let full = format!("{}/{}", path, d.name);
1440            if full.trim_start_matches('/') == resolved_ds {
1441                for a in &d.attributes {
1442                    if a.source == LayoutSource::Constant {
1443                        attrs.push((a.name.clone(), a.data_type, a.value.clone()));
1444                    }
1445                }
1446            }
1447        });
1448        for (name, dtype, value) in &attrs {
1449            match dtype {
1450                LayoutDataType::Int => {
1451                    let v: i64 = value.trim().parse().unwrap_or(0);
1452                    swmr.set_dataset_attr_numeric(ds_index, name, &v)
1453                }
1454                LayoutDataType::Float => {
1455                    let v: f64 = value.trim().parse().unwrap_or(0.0);
1456                    swmr.set_dataset_attr_numeric(ds_index, name, &v)
1457                }
1458                LayoutDataType::String => swmr.set_dataset_attr_string(ds_index, name, value),
1459            }
1460            .map_err(|e| {
1461                ADError::UnsupportedConversion(format!(
1462                    "SWMR layout dataset attribute '{}': {}",
1463                    name, e
1464                ))
1465            })?;
1466        }
1467        Ok(())
1468    }
1469
1470    /// Resolve the on-disk dataset/group paths from the loaded layout XML.
1471    ///
1472    /// With a valid layout this places the image dataset at the layout's
1473    /// `det_default` dataset path, NDAttribute datasets under the
1474    /// `ndattr_default` group, and the performance dataset under the group
1475    /// holding the `timestamp` dataset — matching C `NDFileHDF5`'s
1476    /// `/entry/instrument/detector/data` tree. Without a layout the flat
1477    /// root defaults (`data`, `NDAttributes`, `performance`) are kept.
1478    ///
1479    /// All returned paths have the leading `/` stripped, since `rust-hdf5`
1480    /// keys datasets/groups without a leading slash.
1481    fn resolve_layout_paths(&mut self) {
1482        let strip = |s: String| s.trim_start_matches('/').to_string();
1483        match self.layout.as_ref() {
1484            Some(layout) => {
1485                self.resolved_dataset_path = layout
1486                    .detector_dataset_path()
1487                    .map(strip)
1488                    .unwrap_or_else(|| self.dataset_name.clone());
1489                self.resolved_ndattr_group =
1490                    layout.ndattr_default_group().map(strip).unwrap_or_default();
1491                self.resolved_perf_group = layout
1492                    .dataset_group_path("timestamp")
1493                    .map(strip)
1494                    .unwrap_or_default();
1495            }
1496            None => {
1497                self.resolved_dataset_path = self.dataset_name.clone();
1498                self.resolved_ndattr_group.clear();
1499                self.resolved_perf_group.clear();
1500            }
1501        }
1502    }
1503
1504    /// Build every group node declared in the loaded layout XML so that empty
1505    /// NeXus-style groups (e.g. an `NXdata` placeholder) also exist on disk,
1506    /// not just the groups implied by the dataset placement. No-op when no
1507    /// layout is loaded.
1508    ///
1509    /// `rust-hdf5` 0.2.15's `create_group` errors on a duplicate path, so each
1510    /// distinct group path is created exactly once via a created-set; paths
1511    /// are processed shortest-first so a parent always exists before a child.
1512    fn build_layout_groups(&self, file: &H5File) -> ADResult<()> {
1513        let layout = match self.layout.as_ref() {
1514            Some(l) => l,
1515            None => return Ok(()),
1516        };
1517        fn collect<'a>(
1518            g: &'a crate::hdf5_layout::LayoutGroup,
1519            prefix: &str,
1520            out: &mut Vec<(String, &'a crate::hdf5_layout::LayoutGroup)>,
1521        ) {
1522            let here = if prefix.is_empty() {
1523                g.name.clone()
1524            } else {
1525                format!("{}/{}", prefix, g.name)
1526            };
1527            out.push((here.clone(), g));
1528            for sub in &g.groups {
1529                collect(sub, &here, out);
1530            }
1531        }
1532        let mut nodes = Vec::new();
1533        for g in &layout.groups {
1534            collect(g, "", &mut nodes);
1535        }
1536        nodes.sort_by_key(|(p, _)| p.matches('/').count());
1537        let mut created: std::collections::HashSet<String> = std::collections::HashSet::new();
1538        for (path, node) in &nodes {
1539            if !created.insert(path.clone()) {
1540                continue;
1541            }
1542            let (parent, leaf) = match path.rsplit_once('/') {
1543                Some((p, l)) => (p, l),
1544                None => ("", path.as_str()),
1545            };
1546            // The parent path was created earlier (shorter, sorted first).
1547            let parent_group = if parent.is_empty() {
1548                None
1549            } else {
1550                Some(Self::open_write_group(file, parent)?)
1551            };
1552            let group = match parent_group.as_ref() {
1553                Some(g) => g.create_group(leaf),
1554                None => file.create_group(leaf),
1555            }
1556            .map_err(|e| {
1557                ADError::UnsupportedConversion(format!("HDF5 layout group '{}': {}", path, e))
1558            })?;
1559            // C attaches the group's constant <attribute> nodes (e.g. the NeXus
1560            // NX_class markers) via writeHdfAttributes (NDFileHDF5.cpp:693-695).
1561            write_group_constant_attrs(&group, &node.attributes);
1562        }
1563        Ok(())
1564    }
1565
1566    /// Materialise every `<hardlink>` declared in the loaded layout XML.
1567    ///
1568    /// A layout `<hardlink name="..." target="..."/>` inside a `<group>`
1569    /// declares an HDF5 hard link: an additional name (`name`, a leaf within
1570    /// the enclosing group) for the object already living at `target` (an
1571    /// absolute object path). C++ `NDFileHDF5::createHardLinks` walks the
1572    /// layout after the groups/datasets exist and calls `H5Lcreate_hard`.
1573    ///
1574    /// Called from `close_file` for the standard (non-SWMR) close path so that
1575    /// both the primary image dataset and the per-frame NDAttribute datasets —
1576    /// any of which a hardlink may target — already exist on disk. No-op when
1577    /// no layout is loaded.
1578    ///
1579    /// `file` is the live `Standard` write-mode HDF5 handle. The SWMR path has
1580    /// its own counterpart, [`build_swmr_layout_hardlinks`], which runs before
1581    /// `start_swmr()` (C++ `NDFileHDF5.cpp:320`-`326`: `createHardLinks` then
1582    /// `startSWMR`) so SWMR readers see the links during streaming.
1583    fn build_layout_hardlinks(&self, file: &H5File) -> ADResult<()> {
1584        let layout = match self.layout.as_ref() {
1585            Some(l) => l,
1586            None => return Ok(()),
1587        };
1588        // Collect (parent_group_path, hardlink) for every group in the tree.
1589        fn collect<'a>(
1590            g: &'a crate::hdf5_layout::LayoutGroup,
1591            prefix: &str,
1592            out: &mut Vec<(String, &'a crate::hdf5_layout::LayoutHardlink)>,
1593        ) {
1594            let here = if prefix.is_empty() {
1595                g.name.clone()
1596            } else {
1597                format!("{}/{}", prefix, g.name)
1598            };
1599            for hl in &g.hardlinks {
1600                out.push((here.clone(), hl));
1601            }
1602            for sub in &g.groups {
1603                collect(sub, &here, out);
1604            }
1605        }
1606        let mut links = Vec::new();
1607        for g in &layout.groups {
1608            collect(g, "", &mut links);
1609        }
1610        for (parent_path, hl) in &links {
1611            // The enclosing group already exists (created by
1612            // `build_layout_groups`); re-open it and create the link inside it.
1613            let parent = Self::open_write_group(file, parent_path)?;
1614            parent.link(&hl.name, &hl.target).map_err(|e| {
1615                ADError::UnsupportedConversion(format!(
1616                    "HDF5 layout hardlink '{}/{}' -> '{}': {}",
1617                    parent_path, hl.name, hl.target, e
1618                ))
1619            })?;
1620        }
1621        Ok(())
1622    }
1623
1624    /// Re-open an already-created group by full path in write mode. In write
1625    /// mode `H5Group::group` returns a handle without verification, so this is
1626    /// a pure handle constructor walking each path segment.
1627    fn open_write_group(file: &H5File, path: &str) -> ADResult<rust_hdf5::H5Group> {
1628        let mut current: Option<rust_hdf5::H5Group> = None;
1629        for seg in path.split('/').filter(|s| !s.is_empty()) {
1630            let next = match current.as_ref() {
1631                Some(g) => g.group(seg),
1632                None => file.root_group().group(seg),
1633            }
1634            .map_err(|e| {
1635                ADError::UnsupportedConversion(format!("HDF5 group reopen '{}': {}", seg, e))
1636            })?;
1637            current = Some(next);
1638        }
1639        current.ok_or_else(|| ADError::UnsupportedConversion("empty group path".into()))
1640    }
1641
1642    /// Create every detector-source dataset on the first frame in standard
1643    /// mode. Each is an extensible `[nframes, .., Y, X]` array whose leading
1644    /// dimension later frames extend (C++ `NDFileHDF5Dataset`). C creates one
1645    /// `NDFileHDF5Dataset` per `<dataset source="detector">` node up front
1646    /// (`createDatasetDetector`, NDFileHDF5.cpp:1324-1357) and routes frames
1647    /// among them; the common single-dataset layout produces exactly one. All
1648    /// datasets share the datatype/dataspace/frame shape taken from this frame.
1649    fn create_detector_datasets(&mut self, array: &NDArray) -> ADResult<()> {
1650        let frame_dims: Vec<usize> = array.dims.iter().rev().map(|d| d.size).collect();
1651        // A pre-compressed input array (codec set) is written verbatim through a
1652        // matching HDF5 filter via direct chunk write (C
1653        // `NDFileHDF5Dataset::writeFile`, the `compressionAware` path). Its
1654        // dataset carries the ORIGINAL element type (`codec.original_data_type`),
1655        // a whole-frame chunk (`compressed_layout`), and a filter pipeline
1656        // mirroring the codec rather than the writer's configured compression.
1657        // An uncompressed array takes the standard tiled-write path unchanged.
1658        let (ds_data_type, shape, chunk, leading, pipeline) = match array.codec.as_ref() {
1659            Some(codec) => {
1660                let pl = self.codec_filter_pipeline(codec).ok_or_else(|| {
1661                    ADError::UnsupportedConversion(format!(
1662                        "HDF5 cannot direct-chunk-write codec '{}' \
1663                         (only jpeg/blosc/lz4/bslz4 are supported)",
1664                        codec.name.as_str()
1665                    ))
1666                })?;
1667                let (shape, chunk, leading) = self.compressed_layout(&frame_dims);
1668                (codec.original_data_type, shape, chunk, leading, Some(pl))
1669            }
1670            None => {
1671                let dt = array.data.data_type();
1672                let (shape, chunk, leading) = self.standard_layout(&frame_dims);
1673                (
1674                    dt,
1675                    shape,
1676                    chunk,
1677                    leading,
1678                    self.build_pipeline(dt.element_size()),
1679                )
1680            }
1681        };
1682        // N-bit packing couples a reduced-precision datatype override with the
1683        // nbit filter (C narrows `this->datatype` then `H5Pset_nbit`); only this
1684        // standard, non-codec path can override the dataset datatype. For a
1685        // pre-compressed array the codec pipeline already governs the dataset,
1686        // so N-bit does not apply. `d_nelmts` is the per-chunk element count.
1687        let (nbit_dt, pipeline): (Option<DatatypeMessage>, Option<FilterPipeline>) =
1688            if array.codec.is_none() {
1689                let chunk_nelmts: usize = chunk.iter().product();
1690                match self.nbit_packing(ds_data_type, chunk_nelmts) {
1691                    Some((dt, pl)) => (Some(dt), Some(pl)),
1692                    None => (None, pipeline),
1693                }
1694            } else {
1695                (None, pipeline)
1696            };
1697        // Max shape: with extra dims the dataset is created at its full fixed
1698        // multi-dimensional size (every leading axis chunked at 1, so its
1699        // ceiling equals its size). Without extra dims the single leading frame
1700        // axis is extensible (`None`). Every other axis gets headroom to the
1701        // chunk-aligned ceiling so a `write_chunk_at` edge tile of a
1702        // non-dividing chunk can extend into it — `close_file`'s `set_extent`
1703        // trims back to the exact frame shape.
1704        let max_shape: Vec<Option<usize>> = shape
1705            .iter()
1706            .zip(chunk.iter())
1707            .enumerate()
1708            .map(|(i, (&s, &c))| {
1709                if leading.is_none() && i == 0 {
1710                    None
1711                } else {
1712                    Some(s.div_ceil(c) * c)
1713                }
1714            })
1715            .collect();
1716
1717        // Build the layout group hierarchy (if a layout XML is loaded) before
1718        // placing the dataset. With no layout this is a no-op and the dataset
1719        // lands flat at the file root.
1720        match self.handle {
1721            Some(Hdf5Handle::Standard { ref file, .. }) => self.build_layout_groups(file)?,
1722            _ => return Err(ADError::UnsupportedConversion("no HDF5 file open".into())),
1723        }
1724
1725        // Enumerate every detector-source dataset (C `detDataMap`), each with
1726        // the `constant` HDF5 attributes the layout XML attaches to it (e.g. the
1727        // NeXus `signal=1` marker). Keys are leading-slash full names so the
1728        // `detector_data_destination` routing value matches C `detDataMap`.
1729        // Only constant attributes are materialised here; `ndattribute`-sourced
1730        // nodes carry per-frame values, out of scope for dataset creation.
1731        let detector_keys: Vec<String> = match self.layout.as_ref() {
1732            Some(l) => {
1733                let mut keys = l.detector_dataset_paths();
1734                if keys.is_empty() {
1735                    keys.push(format!("/{}", self.resolved_dataset_path));
1736                }
1737                keys
1738            }
1739            None => vec![format!("/{}", self.resolved_dataset_path)],
1740        };
1741        let detector_specs: Vec<(
1742            String,
1743            Vec<(String, crate::hdf5_layout::LayoutDataType, String)>,
1744        )> = detector_keys
1745            .iter()
1746            .map(|key| {
1747                let stripped = key.trim_start_matches('/').to_string();
1748                let attrs = self
1749                    .layout
1750                    .as_ref()
1751                    .map(|l| {
1752                        use crate::hdf5_layout::LayoutSource;
1753                        let mut out = Vec::new();
1754                        l.for_each_dataset(|path, d| {
1755                            let full = format!("{}/{}", path, d.name);
1756                            if full.trim_start_matches('/') == stripped {
1757                                for a in &d.attributes {
1758                                    if a.source == LayoutSource::Constant {
1759                                        out.push((a.name.clone(), a.data_type, a.value.clone()));
1760                                    }
1761                                }
1762                            }
1763                        });
1764                        out
1765                    })
1766                    .unwrap_or_default();
1767                (key.clone(), attrs)
1768            })
1769            .collect();
1770
1771        let dtype_ordinal = ds_data_type as i32;
1772        let fill = self.fill_value;
1773        let row_chunks = self.chunk.n_row_chunks as i32;
1774        let col_chunks = self.chunk.n_col_chunks as i32;
1775        let frame_chunks = self.chunk.n_frames_chunks as i32;
1776        let n_extra = self.n_extra_dims as i32;
1777        let extra_meta: Vec<(usize, i32, String)> = (0..self.n_extra_dims)
1778            .map(|i| {
1779                (
1780                    i,
1781                    self.extra_dims[i].size.max(1) as i32,
1782                    self.extra_dims[i].name.clone(),
1783                )
1784            })
1785            .collect();
1786
1787        // C writeDefaultDatasetAttributes (NDFileHDF5.cpp:3695-3719) attaches
1788        // NDArrayNumDims (scalar int32) and one int32 value per dimension for
1789        // NDArrayDimOffset/Binning/Reverse to every detector dataset. Dim order
1790        // is native NDArray order (dims[0]…), not the reversed HDF5 axis order.
1791        let nd_num_dims = array.dims.len() as i32;
1792        // writeH5attrInt32 (NDFileHDF5.cpp:1142-1191) writes a single value as a
1793        // scalar and multiple values as a 1-D int32 array of length ndims. The
1794        // per-dimension values, native NDArray order.
1795        let dim_offsets: Vec<i32> = array.dims.iter().map(|d| d.offset as i32).collect();
1796        let dim_binnings: Vec<i32> = array.dims.iter().map(|d| d.binning as i32).collect();
1797        let dim_reverses: Vec<i32> = array.dims.iter().map(|d| d.reverse as i32).collect();
1798
1799        macro_rules! create_ds {
1800            ($t:ty, $h5file:expr, $ds_group:expr, $ds_name:expr, $constant_attrs:expr) => {{
1801                let mut builder = match $ds_group.as_ref() {
1802                    Some(g) => g.new_dataset::<$t>(),
1803                    None => $h5file.new_dataset::<$t>(),
1804                }
1805                .shape(&shape[..])
1806                .chunk(&chunk[..])
1807                .max_shape(&max_shape[..])
1808                // C parity: NDFileHDF5 sets HDF5_fillValue on the dataset
1809                // creation property list (H5Pset_fill_value). rust-hdf5 0.2.15
1810                // exposes `DatasetBuilder::fill_value`, which writes it into the
1811                // DCPL fill-value message so unwritten chunks read back as
1812                // `fill` rather than zero.
1813                .fill_value(fill as $t);
1814                // N-bit: store the reduced-precision datatype (C narrows
1815                // `this->datatype`). The byte footprint stays `size_of::<$t>()`;
1816                // the nbit filter packs the significant bits within it.
1817                if let Some(ref dt) = nbit_dt {
1818                    builder = builder.datatype(dt.clone());
1819                }
1820                if let Some(ref pl) = pipeline {
1821                    builder = builder.filter_pipeline(pl.clone());
1822                }
1823                let ds = builder.create($ds_name.as_str()).map_err(|e| {
1824                    ADError::UnsupportedConversion(format!("HDF5 dataset error: {}", e))
1825                })?;
1826                // Record the exact NDArray data type for lossless read-back.
1827                let _ = ds
1828                    .new_attr::<i32>()
1829                    .shape(())
1830                    .create(DTYPE_ATTR)
1831                    .and_then(|a| a.write_numeric(&dtype_ordinal));
1832                // Also expose the fill value as an attribute for tooling that
1833                // inspects HDF5_fillValue directly (the DCPL above is the
1834                // authoritative copy).
1835                let _ = ds
1836                    .new_attr::<f64>()
1837                    .shape(())
1838                    .create("HDF5_fillValue")
1839                    .and_then(|a| a.write_numeric(&fill));
1840                // Record the requested chunk geometry. The on-disk chunk is
1841                // one frame per chunk (crate limitation); these attributes
1842                // preserve the user's intent for downstream tooling.
1843                for (name, val) in [
1844                    ("HDF5_nRowChunks", row_chunks),
1845                    ("HDF5_nColChunks", col_chunks),
1846                    ("HDF5_nFramesChunks", frame_chunks),
1847                    ("HDF5_nExtraDims", n_extra),
1848                ] {
1849                    let _ = ds
1850                        .new_attr::<i32>()
1851                        .shape(())
1852                        .create(name)
1853                        .and_then(|a| a.write_numeric(&val));
1854                }
1855                // Record extra-dimension sizes and names so the flat leading
1856                // axis can be reshaped into the intended N-D layout.
1857                for (i, size, name) in &extra_meta {
1858                    let _ = ds
1859                        .new_attr::<i32>()
1860                        .shape(())
1861                        .create(&format!("HDF5_extraDimSize{}", i))
1862                        .and_then(|a| a.write_numeric(size));
1863                    if !name.is_empty() {
1864                        let s = rust_hdf5::types::VarLenUnicode(name.clone());
1865                        let _ = ds
1866                            .new_attr::<rust_hdf5::types::VarLenUnicode>()
1867                            .shape(())
1868                            .create(&format!("HDF5_extraDimName{}", i))
1869                            .and_then(|a| a.write_scalar(&s));
1870                    }
1871                }
1872                // Materialise the layout XML's constant dataset attributes
1873                // (e.g. NeXus `signal=1`), typed per the XML `type` attribute.
1874                for (aname, atype, avalue) in $constant_attrs {
1875                    use crate::hdf5_layout::LayoutDataType;
1876                    match atype {
1877                        LayoutDataType::Int => {
1878                            let v: i64 = avalue.trim().parse().unwrap_or(0);
1879                            let _ = ds
1880                                .new_attr::<i64>()
1881                                .shape(())
1882                                .create(aname)
1883                                .and_then(|a| a.write_numeric(&v));
1884                        }
1885                        LayoutDataType::Float => {
1886                            let v: f64 = avalue.trim().parse().unwrap_or(0.0);
1887                            let _ = ds
1888                                .new_attr::<f64>()
1889                                .shape(())
1890                                .create(aname)
1891                                .and_then(|a| a.write_numeric(&v));
1892                        }
1893                        LayoutDataType::String => {
1894                            let s = rust_hdf5::types::VarLenUnicode(avalue.clone());
1895                            let _ = ds
1896                                .new_attr::<rust_hdf5::types::VarLenUnicode>()
1897                                .shape(())
1898                                .create(aname)
1899                                .and_then(|a| a.write_scalar(&s));
1900                        }
1901                    }
1902                }
1903                // C parity (writeDefaultDatasetAttributes): NDArrayNumDims plus
1904                // the per-dimension offset/binning/reverse. writeH5attrInt32
1905                // (NDFileHDF5.cpp:1142-1191) emits a single dimension as a scalar
1906                // int32 and multiple dimensions as a 1-D int32 array of length
1907                // ndims.
1908                let _ = ds
1909                    .new_attr::<i32>()
1910                    .shape(())
1911                    .create("NDArrayNumDims")
1912                    .and_then(|a| a.write_numeric(&nd_num_dims));
1913                for (name, vals) in [
1914                    ("NDArrayDimOffset", &dim_offsets),
1915                    ("NDArrayDimBinning", &dim_binnings),
1916                    ("NDArrayDimReverse", &dim_reverses),
1917                ] {
1918                    let _ = if vals.len() == 1 {
1919                        ds.new_attr::<i32>()
1920                            .shape(())
1921                            .create(name)
1922                            .and_then(|a| a.write_numeric(&vals[0]))
1923                    } else {
1924                        ds.new_attr::<i32>()
1925                            .shape([vals.len()])
1926                            .create(name)
1927                            .and_then(|a| a.write_array(vals))
1928                    };
1929                }
1930                ds
1931            }};
1932        }
1933
1934        let h5file = match self.handle {
1935            Some(Hdf5Handle::Standard { ref file, .. }) => file,
1936            _ => return Err(ADError::UnsupportedConversion("no HDF5 file open".into())),
1937        };
1938
1939        let mut detectors: std::collections::HashMap<String, DetectorDataset> =
1940            std::collections::HashMap::with_capacity(detector_specs.len());
1941        for (key, constant_attrs) in &detector_specs {
1942            // Resolve each dataset's parent group and leaf name. A key is e.g.
1943            // `/entry/instrument/detector/data` with a layout, or `/data` flat.
1944            let stripped = key.trim_start_matches('/');
1945            let (ds_group, ds_name): (Option<rust_hdf5::H5Group>, String) =
1946                match stripped.rsplit_once('/') {
1947                    Some((group_path, leaf)) => (
1948                        Some(Self::open_write_group(h5file, group_path)?),
1949                        leaf.to_string(),
1950                    ),
1951                    None => (None, stripped.to_string()),
1952                };
1953            // Dispatch the dataset element type on `ds_data_type`: the original
1954            // (uncompressed) type for a pre-compressed array — whose `array.data`
1955            // is the collapsed `U8` byte buffer — and the buffer's own type
1956            // otherwise.
1957            let ds = match ds_data_type {
1958                NDDataType::Int8 => create_ds!(i8, h5file, ds_group, ds_name, constant_attrs),
1959                NDDataType::UInt8 => create_ds!(u8, h5file, ds_group, ds_name, constant_attrs),
1960                NDDataType::Int16 => create_ds!(i16, h5file, ds_group, ds_name, constant_attrs),
1961                NDDataType::UInt16 => create_ds!(u16, h5file, ds_group, ds_name, constant_attrs),
1962                NDDataType::Int32 => create_ds!(i32, h5file, ds_group, ds_name, constant_attrs),
1963                NDDataType::UInt32 => create_ds!(u32, h5file, ds_group, ds_name, constant_attrs),
1964                NDDataType::Int64 => create_ds!(i64, h5file, ds_group, ds_name, constant_attrs),
1965                NDDataType::UInt64 => create_ds!(u64, h5file, ds_group, ds_name, constant_attrs),
1966                NDDataType::Float32 => create_ds!(f32, h5file, ds_group, ds_name, constant_attrs),
1967                NDDataType::Float64 => create_ds!(f64, h5file, ds_group, ds_name, constant_attrs),
1968            };
1969            detectors.insert(
1970                key.clone(),
1971                DetectorDataset {
1972                    ds,
1973                    frame_count: 0,
1974                    frame_band: Vec::new(),
1975                },
1976            );
1977        }
1978
1979        if let Some(Hdf5Handle::Standard { detectors: d, .. }) = self.handle.as_mut() {
1980            *d = detectors;
1981        }
1982        self.open_data_type = Some(ds_data_type);
1983        self.open_frame_dims = Some(frame_dims);
1984        self.open_codec = array.codec.clone();
1985        Ok(())
1986    }
1987
1988    /// Resolve which detector dataset a frame is written to (C
1989    /// `NDFileHDF5::writeFile`, NDFileHDF5.cpp:1449-1474). The default is
1990    /// `defDsetName` — the leading-slash full name of the `det_default`/first
1991    /// detector dataset (= `resolved_dataset_path`). When the layout's
1992    /// `<global name="detector_data_destination" ndattribute="X"/>` names an
1993    /// NDAttribute that the frame carries as a *string* whose value is an
1994    /// existing detector-dataset key, that key is the destination; an unknown
1995    /// value falls back to the default. A present-but-non-string attribute is
1996    /// an error, matching C (`getValue(NDAttrString,…)` → `ND_ERROR`, which
1997    /// aborts the write).
1998    fn resolve_destination_key(&self, array: &NDArray) -> ADResult<String> {
1999        let default = format!("/{}", self.resolved_dataset_path);
2000        let attr_name = self
2001            .layout
2002            .as_ref()
2003            .and_then(|l| l.detector_data_destination.as_deref())
2004            .filter(|s| !s.is_empty());
2005        let Some(attr_name) = attr_name else {
2006            return Ok(default);
2007        };
2008        // C 1451-1452: a missing destination attribute keeps the default with
2009        // no error (only a present-but-unreadable one aborts).
2010        let Some(attr) = array.attributes.get(attr_name) else {
2011            return Ok(default);
2012        };
2013        match attr.value.as_string_typed() {
2014            Some(value) => {
2015                let known = matches!(
2016                    &self.handle,
2017                    Some(Hdf5Handle::Standard { detectors, .. })
2018                        if detectors.contains_key(value)
2019                );
2020                Ok(if known { value.to_string() } else { default })
2021            }
2022            None => Err(ADError::UnsupportedConversion(format!(
2023                "HDF5 detector_data_destination attribute '{}' is not a string",
2024                attr_name
2025            ))),
2026        }
2027    }
2028
2029    /// Write a frame in standard (non-SWMR) mode, routing it to its detector
2030    /// dataset (C `detector_data_destination`) and extending that dataset's
2031    /// leading dimension. Each detector dataset keeps its own frame counter and
2032    /// band; the file-wide `frame_count` is the total across all of them.
2033    fn write_standard(&mut self, array: &NDArray) -> ADResult<()> {
2034        if self.frame_count == 0 {
2035            self.create_detector_datasets(array)?;
2036            self.create_attribute_datasets(array);
2037        }
2038
2039        let frame_dims = self
2040            .open_frame_dims
2041            .clone()
2042            .ok_or_else(|| ADError::UnsupportedConversion("dataset not initialised".into()))?;
2043        let cur_dims: Vec<usize> = array.dims.iter().rev().map(|d| d.size).collect();
2044        if cur_dims != frame_dims {
2045            return Err(ADError::UnsupportedConversion(format!(
2046                "HDF5 frame shape changed mid-stream: {:?} != {:?}",
2047                cur_dims, frame_dims
2048            )));
2049        }
2050
2051        // verifyChunking codec-match (C `NDFileHDF5Dataset.cpp:194-200`): the
2052        // file's codec is fixed when the dataset is created from the first frame.
2053        // A later frame whose codec differs (including compressed↔uncompressed)
2054        // cannot be written into it.
2055        if !codecs_match(self.open_codec.as_ref(), array.codec.as_ref()) {
2056            return Err(ADError::UnsupportedConversion(format!(
2057                "HDF5 codec changed mid-stream: dataset codec {:?} != frame codec {:?}",
2058                self.open_codec.as_ref().map(|c| c.name),
2059                array.codec.as_ref().map(|c| c.name),
2060            )));
2061        }
2062
2063        let (_shape, chunk, leading) = self.standard_layout(&frame_dims);
2064        let fc = chunk[0];
2065
2066        // Resolve the destination dataset (C `detDataMap` routing) and serialize
2067        // the per-frame payload before borrowing the detector map mutably. A
2068        // pre-compressed frame becomes one direct-chunk-write byte stream (C
2069        // `NDFileHDF5Dataset::writeFile`, compressionAware path); an uncompressed
2070        // frame becomes little-endian pixel bytes for band tiling.
2071        let dest_key = self.resolve_destination_key(array)?;
2072        let chunk_bytes = array.codec.as_ref().map(|codec| {
2073            let total_bytes =
2074                frame_dims.iter().product::<usize>() * codec.original_data_type.element_size();
2075            codec_chunk_bytes(codec, total_bytes, array.data.as_u8_slice())
2076        });
2077        let frame_le = if chunk_bytes.is_none() {
2078            Some(nd_buffer_to_le_bytes(&array.data))
2079        } else {
2080            None
2081        };
2082        let elem_size = array.data.data_type().element_size();
2083
2084        {
2085            let Some(Hdf5Handle::Standard { detectors, .. }) = self.handle.as_mut() else {
2086                return Err(ADError::UnsupportedConversion(
2087                    "HDF5 detector datasets not initialised".into(),
2088                ));
2089            };
2090            let det = detectors.get_mut(&dest_key).ok_or_else(|| {
2091                ADError::UnsupportedConversion(format!(
2092                    "HDF5 destination dataset '{}' not found",
2093                    dest_key
2094                ))
2095            })?;
2096
2097            // Per-dataset leading-axis index. With a fixed extra-dim layout the
2098            // counter must not exceed the product of the extra-dim sizes.
2099            let frame_idx = det.frame_count;
2100            if let Some(ref lead) = leading {
2101                let total: usize = lead.iter().product();
2102                if frame_idx >= total {
2103                    return Err(ADError::UnsupportedConversion(format!(
2104                        "HDF5 extra-dimension capacity exceeded: frame {} >= {}",
2105                        frame_idx, total
2106                    )));
2107                }
2108            }
2109
2110            if let Some(cb) = &chunk_bytes {
2111                // Direct chunk write. The dataset was created with a whole-frame
2112                // chunk (`compressed_layout`), so each frame is exactly one chunk
2113                // and its linear chunk index equals the per-dataset frame index
2114                // (every leading chunk is 1, whether single-axis or extra-dim).
2115                // filter_mask = 0: the codec already applied the full pipeline.
2116                det.ds.write_chunk_raw(frame_idx, cb, 0).map_err(|e| {
2117                    ADError::UnsupportedConversion(format!("HDF5 direct chunk write error: {}", e))
2118                })?;
2119            } else {
2120                // Uncompressed: frames accumulate in this dataset's band and a
2121                // full `fc`-deep band is flushed as a grid of write_chunk_at
2122                // tiles; close_file flushes the partial final band. With a fixed
2123                // multi-extra-dim layout every leading chunk is 1, so `fc == 1`
2124                // and each frame is placed at its odometer position via `unravel`.
2125                let fle = frame_le.expect("frame_le is set whenever chunk_bytes is None");
2126                det.frame_band.push(fle);
2127                if det.frame_band.len() >= fc {
2128                    let leading_coords = match &leading {
2129                        Some(lead) => Self::unravel(frame_idx, lead),
2130                        None => vec![frame_idx / fc],
2131                    };
2132                    Self::flush_band(
2133                        &det.ds,
2134                        &leading_coords,
2135                        &det.frame_band,
2136                        &frame_dims,
2137                        &chunk,
2138                        elem_size,
2139                    )?;
2140                    det.frame_band.clear();
2141                }
2142            }
2143            det.frame_count += 1;
2144        }
2145
2146        // Append NDAttribute values for this frame.
2147        if self.store_attributes {
2148            for ad in self.attr_datasets.iter_mut() {
2149                let value = array
2150                    .attributes
2151                    .get(&ad.name)
2152                    .map(|a| a.value.clone())
2153                    .unwrap_or(NDAttrValue::Undefined);
2154                ad.push(&value);
2155            }
2156        }
2157        Ok(())
2158    }
2159
2160    /// Create one attribute time-series dataset per NDAttribute, preserving
2161    /// the NDAttrValue numeric type. Mirrors C++ `createAttributeDataset`.
2162    fn create_attribute_datasets(&mut self, array: &NDArray) {
2163        self.attr_datasets.clear();
2164        if !self.store_attributes {
2165            return;
2166        }
2167        for attr in array.attributes.iter() {
2168            self.attr_datasets.push(AttributeDataset::new(attr));
2169        }
2170    }
2171
2172    /// Effective chunk depth for the NDAttribute datasets and the performance
2173    /// dataset's leading dimension, mirroring C `calculateAttributeChunking`
2174    /// (NDFileHDF5.cpp:2869-2920). The `HDF5_NDAttributeChunk` param (0 = auto):
2175    /// when auto, Single mode chunks at 1; otherwise at the capture target
2176    /// (`NDFileNumCapture`), or `16*1024` when that is unlimited (≤ 0).
2177    fn attribute_chunking(&self) -> usize {
2178        if self.chunk.ndattr_chunk != 0 {
2179            return self.chunk.ndattr_chunk;
2180        }
2181        if self.open_mode == NDFileMode::Single {
2182            return 1;
2183        }
2184        if self.num_capture > 0 {
2185            self.num_capture
2186        } else {
2187            16 * 1024
2188        }
2189    }
2190
2191    /// Reset and seed the NDAttribute element-attr value caches (ADP-79) from
2192    /// the open-time frame. The distinct referenced names are cached so the
2193    /// per-frame update (`update_ndattr_element_values`) need not re-walk the
2194    /// layout. No-op unless the layout declares `<attribute source="ndattribute">`
2195    /// on a group or dataset (the default NeXus layout declares none).
2196    fn seed_ndattr_element_values(&mut self, array: &NDArray) {
2197        self.ndattr_first_values.clear();
2198        self.ndattr_last_values.clear();
2199        self.ndattr_element_names.clear();
2200        let Some(layout) = self.layout.as_ref() else {
2201            return;
2202        };
2203        let mut names: Vec<String> = Vec::new();
2204        for e in layout.ndattribute_element_attrs() {
2205            if !names.contains(&e.ndattribute) {
2206                names.push(e.ndattribute);
2207            }
2208        }
2209        for name in &names {
2210            if let Some(attr) = array.attributes.get(name) {
2211                self.ndattr_first_values
2212                    .insert(name.clone(), attr.value.clone());
2213                self.ndattr_last_values
2214                    .insert(name.clone(), attr.value.clone());
2215            }
2216        }
2217        self.ndattr_element_names = names;
2218    }
2219
2220    /// Update the last-frame value cache for every referenced element-attr
2221    /// NDAttribute name (C `pFileAttributes` tracks the most recent frame, so a
2222    /// `when="OnFileClose"` attribute records the final value). First-frame
2223    /// values are left untouched — an attribute absent at open stays unwritten
2224    /// for `OnFileOpen`/`OnFrame`, matching C's open-time `find` miss.
2225    fn update_ndattr_element_values(&mut self, array: &NDArray) {
2226        if self.ndattr_element_names.is_empty() {
2227            return;
2228        }
2229        for name in &self.ndattr_element_names {
2230            if let Some(attr) = array.attributes.get(name) {
2231                self.ndattr_last_values
2232                    .insert(name.clone(), attr.value.clone());
2233            }
2234        }
2235    }
2236
2237    /// Materialise every layout `<attribute source="ndattribute">` as an HDF5
2238    /// attribute on its group/dataset (C `storeOnOpenCloseAttribute`). Called at
2239    /// close on the standard path, when every group and dataset exists: an
2240    /// `OnFileOpen`/`OnFrame` attribute takes the first-frame value (C writes it
2241    /// at open, where a later close re-create would fail as a duplicate, so the
2242    /// open value wins), an `OnFileClose` attribute the last-frame value.
2243    /// `OnFileWrite` is not materialised by this path, matching C. The HDF5
2244    /// attribute datatype follows the live NDAttribute value (C `typeNd2Hdf` on
2245    /// the runtime type); an undefined/absent value is skipped.
2246    ///
2247    /// Group attributes are written through the file handle. Dataset attributes
2248    /// need a live dataset handle: a detector dataset retains its create-time
2249    /// handle in `detectors` at close, and the lazily-created attribute/
2250    /// performance datasets are reopened by full path through
2251    /// `H5File::dataset_writer` (rust-hdf5 0.2.22), so dataset element-attrs are
2252    /// honoured for every dataset.
2253    fn flush_ndattr_element_attrs(
2254        &self,
2255        file: &H5File,
2256        detectors: &std::collections::HashMap<String, DetectorDataset>,
2257    ) -> ADResult<()> {
2258        use crate::hdf5_layout::LayoutWhen;
2259        let Some(layout) = self.layout.as_ref() else {
2260            return Ok(());
2261        };
2262        for e in layout.ndattribute_element_attrs() {
2263            let value = match e.when {
2264                LayoutWhen::OnFileOpen | LayoutWhen::OnFrame => {
2265                    self.ndattr_first_values.get(&e.ndattribute)
2266                }
2267                LayoutWhen::OnFileClose => self.ndattr_last_values.get(&e.ndattribute),
2268                LayoutWhen::OnFileWrite => None,
2269            };
2270            let Some(value) = value else {
2271                continue;
2272            };
2273            let path = e.element_path.trim_start_matches('/');
2274            if e.is_dataset {
2275                // A detector dataset retains its create-time write handle in
2276                // `detectors` (keyed by leading-slash full name); the lazily-
2277                // created attribute/performance datasets do not. Both exist on
2278                // disk by now (close-path ordering), so reopen the latter by
2279                // full path in write mode (rust-hdf5 0.2.22 `dataset_writer`,
2280                // which registers names as slash-trimmed full paths).
2281                if let Some(det) = detectors.get(&e.element_path) {
2282                    write_ndattr_dataset_attr(&det.ds, &e.attr_name, value);
2283                } else if let Ok(ds) = file.dataset_writer(path) {
2284                    write_ndattr_dataset_attr(&ds, &e.attr_name, value);
2285                }
2286            } else {
2287                let group = Self::open_write_group(file, path)?;
2288                write_ndattr_group_attr(&group, &e.attr_name, value);
2289            }
2290        }
2291        Ok(())
2292    }
2293
2294    /// SWMR counterpart of C `writeDefaultDatasetAttributes`
2295    /// (NDFileHDF5.cpp:3695-3719): attach `NDArrayNumDims` (scalar int32) and the
2296    /// per-dimension `NDArrayDimOffset`/`NDArrayDimBinning`/`NDArrayDimReverse` to
2297    /// the single streaming dataset (addressed by `ds_index`). A 1-D array writes
2298    /// each as a scalar int32, a multi-dim array as a 1-D int32 array of length
2299    /// ndims (C `writeH5attrInt32`, NDFileHDF5.cpp:1142-1191), native NDArray dim
2300    /// order. Must run before `start_swmr()` — HDF5 forbids adding attributes
2301    /// after the SWMR lock.
2302    fn write_swmr_ndarray_default_attrs(
2303        &self,
2304        swmr: &mut SwmrFileWriter,
2305        ds_index: usize,
2306        array: &NDArray,
2307    ) -> ADResult<()> {
2308        let nd_num_dims = array.dims.len() as i32;
2309        swmr.set_dataset_attr_numeric(ds_index, "NDArrayNumDims", &nd_num_dims)
2310            .map_err(|e| {
2311                ADError::UnsupportedConversion(format!("SWMR NDArrayNumDims attr: {}", e))
2312            })?;
2313        let dim_offsets: Vec<i32> = array.dims.iter().map(|d| d.offset as i32).collect();
2314        let dim_binnings: Vec<i32> = array.dims.iter().map(|d| d.binning as i32).collect();
2315        let dim_reverses: Vec<i32> = array.dims.iter().map(|d| d.reverse as i32).collect();
2316        for (name, vals) in [
2317            ("NDArrayDimOffset", &dim_offsets),
2318            ("NDArrayDimBinning", &dim_binnings),
2319            ("NDArrayDimReverse", &dim_reverses),
2320        ] {
2321            let res = if vals.len() == 1 {
2322                swmr.set_dataset_attr_numeric(ds_index, name, &vals[0])
2323            } else {
2324                swmr.set_dataset_attr_array(ds_index, name, &[vals.len() as u64], vals)
2325            };
2326            res.map_err(|e| ADError::UnsupportedConversion(format!("SWMR {} attr: {}", name, e)))?;
2327        }
2328        Ok(())
2329    }
2330
2331    /// SWMR counterpart of [`Hdf5Writer::flush_ndattr_element_attrs`], limited
2332    /// to the open-time set that can be written before `start_swmr()` locks the
2333    /// file: `OnFileOpen`/`OnFrame` `<attribute source="ndattribute">` nodes,
2334    /// using the first-frame value. Group attrs address the group by path;
2335    /// dataset attrs address the single streaming dataset by its index
2336    /// (`ds_index`) when their element path matches it — rust-hdf5 0.2.22's
2337    /// `set_dataset_attr_*` makes dataset element-attrs addressable in SWMR.
2338    /// `OnFileClose` cannot be honoured in SWMR (HDF5 forbids creating
2339    /// attributes after the SWMR lock — C's close-time `H5Acreate2` fails
2340    /// identically); that one remains a recorded residual.
2341    fn write_swmr_ndattr_element_attrs(
2342        &self,
2343        swmr: &mut SwmrFileWriter,
2344        ds_index: usize,
2345    ) -> ADResult<()> {
2346        use crate::hdf5_layout::LayoutWhen;
2347        let Some(layout) = self.layout.as_ref() else {
2348            return Ok(());
2349        };
2350        for e in layout.ndattribute_element_attrs() {
2351            if !matches!(e.when, LayoutWhen::OnFileOpen | LayoutWhen::OnFrame) {
2352                continue;
2353            }
2354            let Some(value) = self.ndattr_first_values.get(&e.ndattribute) else {
2355                continue;
2356            };
2357            if e.is_dataset {
2358                // Only the single streaming image dataset exists in SWMR; attach
2359                // by its known index when the element path resolves to it.
2360                // `resolved_dataset_path` is stored slash-trimmed.
2361                if e.element_path.trim_start_matches('/') == self.resolved_dataset_path {
2362                    write_swmr_ndattr_dataset_attr(swmr, ds_index, &e.attr_name, value)?;
2363                }
2364            } else {
2365                write_swmr_ndattr_group_attr(swmr, &e.element_path, &e.attr_name, value)?;
2366            }
2367        }
2368        Ok(())
2369    }
2370
2371    /// Flush accumulated NDAttribute datasets into the open standard file.
2372    /// Each becomes a chunked, extensible 1-D dataset under `NDAttributes/`.
2373    fn flush_attribute_datasets(&mut self) -> ADResult<()> {
2374        if self.attr_datasets.is_empty() {
2375            return Ok(());
2376        }
2377        let chunk_depth = self.attribute_chunking();
2378        let ndattr_group = self.resolved_ndattr_group.clone();
2379        // Route each attribute to its XML-declared `<dataset source="ndattribute">`
2380        // parent group + dataset name (C `find_dset_ndattr`, NDFileHDF5.cpp:2792),
2381        // falling back to the default ndattr group keyed by the raw attribute name.
2382        let targets: Vec<(String, String)> = self
2383            .attr_datasets
2384            .iter()
2385            .map(|ad| {
2386                match self
2387                    .layout
2388                    .as_ref()
2389                    .and_then(|l| l.ndattribute_dataset(&ad.name))
2390                {
2391                    Some((g, name)) => (g.trim_start_matches('/').to_string(), name),
2392                    None => (ndattr_group.clone(), ad.name.clone()),
2393                }
2394            })
2395            .collect();
2396        let h5file = match self.handle {
2397            Some(Hdf5Handle::Standard { ref file, .. }) => file,
2398            _ => return Ok(()),
2399        };
2400        // Group handles, cached by path. A non-empty path is a layout group
2401        // already created by `build_layout_groups` (re-open it); an empty path
2402        // is the flat `NDAttributes` fallback (no layout) created at the root.
2403        let mut group_cache: std::collections::HashMap<String, rust_hdf5::H5Group> =
2404            std::collections::HashMap::new();
2405
2406        for (ad, (group_path, ds_name)) in self.attr_datasets.iter().zip(targets.iter()) {
2407            if ad.frames == 0 {
2408                continue;
2409            }
2410            let n = ad.frames;
2411            // C does not clamp the chunk to the frame count: the attribute
2412            // dataset is extensible (unlimited dim 0), so the chunk may exceed
2413            // the current extent (`calculateAttributeChunking` → numCapture).
2414            let chunk = chunk_depth;
2415
2416            if !group_cache.contains_key(group_path) {
2417                let g = if group_path.is_empty() {
2418                    h5file.create_group("NDAttributes").map_err(|e| {
2419                        ADError::UnsupportedConversion(format!("HDF5 group error: {}", e))
2420                    })?
2421                } else {
2422                    Self::open_write_group(h5file, group_path)?
2423                };
2424                group_cache.insert(group_path.clone(), g);
2425            }
2426            let group = &group_cache[group_path];
2427
2428            macro_rules! create_attr_ds {
2429                ($t:ty) => {{
2430                    let es = std::mem::size_of::<$t>();
2431                    let ds = group
2432                        .new_dataset::<$t>()
2433                        .shape(&[n])
2434                        .chunk(&[chunk])
2435                        .max_shape(&[None])
2436                        .create(ds_name)
2437                        .map_err(|e| {
2438                            ADError::UnsupportedConversion(format!(
2439                                "HDF5 attribute dataset error: {}",
2440                                e
2441                            ))
2442                        })?;
2443                    // One chunk holds `chunk` consecutive frames; write each
2444                    // chunk's whole byte span (zero-padded for the trailing
2445                    // partial chunk, as rust-hdf5 requires full-chunk writes).
2446                    write_chunked_buffer(&ds, &ad.buffer, chunk * es)?;
2447                    ds
2448                }};
2449            }
2450
2451            let ds = match ad.data_type {
2452                NDAttrDataType::Int8 => create_attr_ds!(i8),
2453                NDAttrDataType::UInt8 => create_attr_ds!(u8),
2454                NDAttrDataType::Int16 => create_attr_ds!(i16),
2455                NDAttrDataType::UInt16 => create_attr_ds!(u16),
2456                NDAttrDataType::Int32 => create_attr_ds!(i32),
2457                NDAttrDataType::UInt32 => create_attr_ds!(u32),
2458                NDAttrDataType::Int64 => create_attr_ds!(i64),
2459                NDAttrDataType::UInt64 => create_attr_ds!(u64),
2460                NDAttrDataType::Float32 => create_attr_ds!(f32),
2461                NDAttrDataType::Float64 => create_attr_ds!(f64),
2462                NDAttrDataType::String => {
2463                    // C stores a string attribute as a rank-1 `[n]` dataset of a
2464                    // fixed 256-byte `H5T_C_S1` string, not a 2-D byte array
2465                    // (NDFileHDF5AttributeDataset.cpp:321-323, rank_=1). The
2466                    // accumulated buffer already holds one 256-byte field per
2467                    // frame, so the element datatype carries the width and the
2468                    // dataset is 1-D.
2469                    let es = MAX_ATTRIBUTE_STRING_SIZE;
2470                    let ds = group
2471                        .new_dataset::<FixedStr256>()
2472                        .shape([n])
2473                        .chunk(&[chunk])
2474                        .max_shape(&[None])
2475                        .create(&ad.name)
2476                        .map_err(|e| {
2477                            ADError::UnsupportedConversion(format!(
2478                                "HDF5 attribute dataset error: {}",
2479                                e
2480                            ))
2481                        })?;
2482                    write_chunked_buffer(&ds, &ad.buffer, chunk * es)?;
2483                    ds
2484                }
2485            };
2486
2487            // C attaches up to four self-describing string attributes to every
2488            // NDAttribute dataset (NDFileHDF5.cpp:2817-2822), each written only
2489            // when non-empty.
2490            write_ndattr_descriptors(&ds, ad);
2491        }
2492        Ok(())
2493    }
2494
2495    /// Write the `timestamp` performance dataset (`[nframes, 5]` doubles)
2496    /// into the open standard file. Mirrors C++ `writePerformanceDataset`.
2497    fn flush_performance_dataset(&mut self) -> ADResult<()> {
2498        if !self.store_performance || self.perf_rows.is_empty() {
2499            return Ok(());
2500        }
2501        let n = self.perf_rows.len();
2502        let mut flat: Vec<f64> = Vec::with_capacity(n * 5);
2503        for row in &self.perf_rows {
2504            flat.extend_from_slice(row);
2505        }
2506        // f64 doubles serialized explicitly little-endian to match the LE
2507        // datatype `rust-hdf5` records (write_chunk copies bytes verbatim).
2508        let raw: Vec<u8> = flat.iter().flat_map(|v| v.to_le_bytes()).collect();
2509
2510        // C `writePerformanceDataset` chunks `[chunking, 5]` where `chunking`
2511        // is the same `calculateAttributeChunking` value (NDFileHDF5.cpp:2645-2647),
2512        // not one row per chunk.
2513        let chunking = self.attribute_chunking();
2514        let perf_group = self.resolved_perf_group.clone();
2515        let h5file = match self.handle {
2516            Some(Hdf5Handle::Standard { ref file, .. }) => file,
2517            _ => return Ok(()),
2518        };
2519        // With a valid layout the performance group (the group holding the
2520        // `timestamp` dataset) was already created by `build_layout_groups`;
2521        // re-open it. Without a layout, fall back to a flat `performance`
2522        // group at the file root.
2523        let group = if perf_group.is_empty() {
2524            h5file
2525                .create_group("performance")
2526                .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 group error: {}", e)))?
2527        } else {
2528            Self::open_write_group(h5file, &perf_group)?
2529        };
2530        let ds = group
2531            .new_dataset::<f64>()
2532            .shape([n, 5])
2533            .chunk(&[chunking, 5])
2534            .max_shape(&[None, Some(5)])
2535            .create("timestamp")
2536            .map_err(|e| {
2537                ADError::UnsupportedConversion(format!("HDF5 performance dataset error: {}", e))
2538            })?;
2539        // One chunk spans `chunking` rows of 5 doubles; the dataset is
2540        // extensible so the final partial band is zero-padded to a full chunk.
2541        write_chunked_buffer(&ds, &raw, chunking * 5 * 8)?;
2542        Ok(())
2543    }
2544
2545    /// Write a frame in SWMR mode.
2546    fn write_swmr(&mut self, array: &NDArray) -> ADResult<()> {
2547        // A pre-compressed frame cannot be direct-chunk-written in SWMR mode:
2548        // `SwmrFileWriter` exposes only `append_frame`, which runs the supplied
2549        // bytes through the dataset's filter pipeline. Feeding already-compressed
2550        // bytes through it would re-compress them into garbage, so the frame is
2551        // rejected (faithful to C returning `asynError` when it cannot write
2552        // pre-compressed data). The standard (non-SWMR) write path direct-chunk-
2553        // writes such frames; SWMR + pre-compressed needs a raw-chunk append API
2554        // the SWMR backend does not yet provide.
2555        if array.codec.is_some() {
2556            return Err(ADError::UnsupportedConversion(
2557                "HDF5 SWMR mode cannot write a pre-compressed array (no raw-chunk \
2558                 append in the SWMR backend); use standard capture mode for \
2559                 direct chunk write"
2560                    .into(),
2561            ));
2562        }
2563
2564        // This frame's 0-based odometer index (`frame_count` increments only
2565        // after `write_swmr` returns); read before the mutable handle borrow.
2566        let frame_idx = self.frame_count;
2567
2568        let (writer, ds_index, grid) = match self.handle {
2569            Some(Hdf5Handle::Swmr {
2570                ref mut writer,
2571                ds_index,
2572                ref grid,
2573                ..
2574            }) => (writer, ds_index, grid),
2575            _ => return Err(ADError::UnsupportedConversion("no SWMR writer open".into())),
2576        };
2577
2578        // The SWMR streaming dataset declares a little-endian element type and
2579        // both `append_frame` and `write_chunk_at` copy the supplied `&[u8]`
2580        // verbatim; serialize to LE explicitly (see `nd_buffer_to_le_bytes`) so
2581        // the file is portable.
2582        let frame_bytes = nd_buffer_to_le_bytes(&array.data);
2583        match grid {
2584            // Fixed multi-extra-dimension grid: place this frame at its odometer
2585            // chunk position(s) via `write_chunk_at`, mirroring the standard
2586            // path's `flush_band` (one frame per leading position, `fc == 1`).
2587            Some(g) => {
2588                let leading_coords = Self::unravel(frame_idx, &g.leading);
2589                for (coords, tile) in Self::band_chunk_writes(
2590                    &leading_coords,
2591                    std::slice::from_ref(&frame_bytes),
2592                    &g.frame_dims,
2593                    &g.chunk,
2594                    g.elem_size,
2595                ) {
2596                    let coords_u64: Vec<u64> = coords.iter().map(|&c| c as u64).collect();
2597                    writer
2598                        .write_chunk_at(ds_index, &coords_u64, &tile)
2599                        .map_err(|e| {
2600                            ADError::UnsupportedConversion(format!(
2601                                "SWMR write_chunk_at error: {}",
2602                                e
2603                            ))
2604                        })?;
2605                }
2606            }
2607            None => {
2608                writer.append_frame(ds_index, &frame_bytes).map_err(|e| {
2609                    ADError::UnsupportedConversion(format!("SWMR append error: {}", e))
2610                })?;
2611            }
2612        }
2613
2614        // Periodic flush
2615        let count = self.frame_count + 1; // will be incremented after return
2616        if self.flush_nth_frame > 0 && count % self.flush_nth_frame == 0 {
2617            writer
2618                .flush()
2619                .map_err(|e| ADError::UnsupportedConversion(format!("SWMR flush error: {}", e)))?;
2620        }
2621        Ok(())
2622    }
2623
2624    /// Record one frame's I/O timing into the performance buffer.
2625    fn record_performance(&mut self, write_duration: f64, frame_bytes: usize) {
2626        let now = std::time::Instant::now();
2627        let first = *self.perf_first.get_or_insert(now);
2628        let runtime = now.duration_since(first).as_secs_f64();
2629        let period = match self.perf_prev {
2630            Some(prev) => now.duration_since(prev).as_secs_f64(),
2631            None => write_duration,
2632        };
2633        self.perf_prev = Some(now);
2634        let fb = frame_bytes as f64;
2635        let inst_speed = if period > 0.0 { fb / period } else { 0.0 };
2636        let avg_speed = if runtime > 0.0 {
2637            (self.perf_rows.len() as f64 + 1.0) * fb / runtime
2638        } else {
2639            0.0
2640        };
2641        self.perf_rows
2642            .push([write_duration, period, runtime, inst_speed, avg_speed]);
2643    }
2644}
2645
2646/// Whether two NDArray codecs are equal for the purpose of C
2647/// `NDFileHDF5Dataset::verifyChunking` (NDFileHDF5Dataset.cpp:194-200), which
2648/// compares `pArray->codec != this->codec`. C `Codec_t::operator!=` (Codec.h)
2649/// equates name, level, shuffle and compressor; `original_data_type` is not part
2650/// of the codec identity (it travels with the array's element type). Two absent
2651/// codecs (an uncompressed file) also match.
2652fn codecs_match(a: Option<&Codec>, b: Option<&Codec>) -> bool {
2653    match (a, b) {
2654        (None, None) => true,
2655        (Some(x), Some(y)) => {
2656            x.name == y.name
2657                && x.level == y.level
2658                && x.shuffle == y.shuffle
2659                && x.compressor == y.compressor
2660        }
2661        _ => false,
2662    }
2663}
2664
2665/// Build the on-disk HDF5 chunk byte stream for one pre-compressed frame,
2666/// matching C `NDFileHDF5Dataset::writeFile` (NDFileHDF5Dataset.cpp:291-329).
2667/// `payload` is the codec's compressed output (this port stores it in the
2668/// array's collapsed `U8` buffer); `uncompressed_total_bytes` is the size of one
2669/// uncompressed frame (C `NDArrayInfo::totalBytes`).
2670///
2671/// - **LZ4**: this port's `compress_lz4` emits a raw LZ4 block with no header
2672///   (C `pArray->pData` likewise), so prepend the 16-byte big-endian header the
2673///   HDF5 LZ4 filter expects — uncompressed size (u64), block size = uncompressed
2674///   size (u32; frames assumed < 1 GiB, as C does), compressed size (u32) — then
2675///   the block.
2676/// - **BSLZ4**: this port's `compress_bslz4` emits the headerless canonical
2677///   bitshuffle+LZ4 stream (C `pArray->pData` likewise — the per-block
2678///   `[u32 nbytes_BE]` headers are part of the stream, but the chunk-level header
2679///   is not), so prepend the 12-byte big-endian header the HDF5 bitshuffle filter
2680///   expects — uncompressed size (u64) and the bitshuffle block size **in bytes**
2681///   (u32 = `block_elems * elem_size`; C hardcodes its 8192 default,
2682///   NDFileHDF5Dataset.cpp:316-328) — then the stream.
2683/// - **BLOSC / JPEG**: this port's codecs already emit the exact on-disk filter
2684///   format (blosc and jpeg streams are self-describing), so the payload is
2685///   written verbatim — the same bytes C produces after its per-codec header step.
2686fn codec_chunk_bytes(codec: &Codec, uncompressed_total_bytes: usize, payload: &[u8]) -> Vec<u8> {
2687    match codec.name {
2688        CodecName::LZ4 => {
2689            let mut out = Vec::with_capacity(16 + payload.len());
2690            out.extend_from_slice(&(uncompressed_total_bytes as u64).to_be_bytes());
2691            out.extend_from_slice(&(uncompressed_total_bytes as u32).to_be_bytes());
2692            out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
2693            out.extend_from_slice(payload);
2694            out
2695        }
2696        CodecName::BSLZ4 => {
2697            let elem_size = codec.original_data_type.element_size();
2698            let block_bytes = crate::codec::bshuf_default_block_size(elem_size) * elem_size;
2699            let mut out = Vec::with_capacity(12 + payload.len());
2700            out.extend_from_slice(&(uncompressed_total_bytes as u64).to_be_bytes());
2701            out.extend_from_slice(&(block_bytes as u32).to_be_bytes());
2702            out.extend_from_slice(payload);
2703            out
2704        }
2705        _ => payload.to_vec(),
2706    }
2707}
2708
2709/// Serialize an NDArray data buffer to **little-endian** bytes.
2710///
2711/// `rust-hdf5` 0.2.15 records every numeric datatype message as little-endian
2712/// (`Endianness::LittleEndian`) and its only chunked-write API, `write_chunk`,
2713/// copies the supplied `&[u8]` verbatim into the chunk with no byte-swap.
2714/// `NDDataBuffer::as_u8_slice()` returns the buffer in *host* byte order, so
2715/// feeding it directly into a typed chunked dataset is correct only on a
2716/// little-endian host. This helper makes the on-disk bytes match the declared
2717/// LE datatype on every host: on LE it is a verbatim copy, on BE it swaps each
2718/// element. Used for every typed-dataset chunk write where no typed
2719/// chunked-write path exists in the crate.
2720fn nd_buffer_to_le_bytes(buf: &NDDataBuffer) -> Vec<u8> {
2721    match buf {
2722        NDDataBuffer::I8(v) => v.iter().map(|&x| x as u8).collect(),
2723        NDDataBuffer::U8(v) => v.clone(),
2724        NDDataBuffer::I16(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2725        NDDataBuffer::U16(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2726        NDDataBuffer::I32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2727        NDDataBuffer::U32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2728        NDDataBuffer::I64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2729        NDDataBuffer::U64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2730        NDDataBuffer::F32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2731        NDDataBuffer::F64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
2732    }
2733}
2734
2735/// Write `buffer` into a chunked dataset, one `chunk_bytes`-sized chunk at a
2736/// time at consecutive linear indices. The trailing partial chunk is
2737/// zero-padded to a full chunk, which `rust-hdf5`'s `write_chunk` requires.
2738fn write_chunked_buffer(
2739    ds: &rust_hdf5::H5Dataset,
2740    buffer: &[u8],
2741    chunk_bytes: usize,
2742) -> ADResult<()> {
2743    let n_chunks = buffer.len().div_ceil(chunk_bytes.max(1));
2744    for c in 0..n_chunks {
2745        let start = c * chunk_bytes;
2746        let end = ((c + 1) * chunk_bytes).min(buffer.len());
2747        let slice = &buffer[start..end];
2748        if slice.len() == chunk_bytes {
2749            ds.write_chunk(c, slice)
2750        } else {
2751            let mut padded = vec![0u8; chunk_bytes];
2752            padded[..slice.len()].copy_from_slice(slice);
2753            ds.write_chunk(c, &padded)
2754        }
2755        .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 chunk write error: {}", e)))?;
2756    }
2757    Ok(())
2758}
2759
2760/// Attach the four self-describing string HDF5 attributes C writes on each
2761/// NDAttribute dataset — `NDAttrName`, `NDAttrDescription`, `NDAttrSourceType`,
2762/// `NDAttrSource` (NDFileHDF5.cpp:2715, 2817-2822) — each only when non-empty
2763/// (C `if (strlen <= 0) continue`). Written as scalar strings, consistent with
2764/// the port's other string dataset attributes.
2765fn write_ndattr_descriptors(ds: &rust_hdf5::H5Dataset, ad: &AttributeDataset) {
2766    for (aname, avalue) in [
2767        ("NDAttrName", ad.name.as_str()),
2768        ("NDAttrDescription", ad.description.as_str()),
2769        ("NDAttrSourceType", ad.source_type.as_str()),
2770        ("NDAttrSource", ad.source.as_str()),
2771    ] {
2772        if avalue.is_empty() {
2773            continue;
2774        }
2775        let s = rust_hdf5::types::VarLenUnicode(avalue.to_string());
2776        let _ = ds
2777            .new_attr::<rust_hdf5::types::VarLenUnicode>()
2778            .shape(())
2779            .create(aname)
2780            .and_then(|a| a.write_scalar(&s));
2781    }
2782}
2783
2784/// Write a live NDAttribute value as an HDF5 attribute on a standard-mode
2785/// group (C `storeOnOpenCloseAttribute` group branch). The datatype follows
2786/// the runtime NDAttribute value (C `typeNd2Hdf`); a string writes a scalar
2787/// string, an `Undefined` value is skipped. Errors are non-fatal, mirroring
2788/// C's per-attribute warn-and-skip.
2789fn write_ndattr_group_attr(group: &rust_hdf5::H5Group, attr_name: &str, value: &NDAttrValue) {
2790    macro_rules! num {
2791        ($v:expr) => {{
2792            let _ = group.set_attr_numeric(attr_name, &$v);
2793        }};
2794    }
2795    match value {
2796        NDAttrValue::Int8(v) => num!(*v),
2797        NDAttrValue::UInt8(v) => num!(*v),
2798        NDAttrValue::Int16(v) => num!(*v),
2799        NDAttrValue::UInt16(v) => num!(*v),
2800        NDAttrValue::Int32(v) => num!(*v),
2801        NDAttrValue::UInt32(v) => num!(*v),
2802        NDAttrValue::Int64(v) => num!(*v),
2803        NDAttrValue::UInt64(v) => num!(*v),
2804        NDAttrValue::Float32(v) => num!(*v),
2805        NDAttrValue::Float64(v) => num!(*v),
2806        NDAttrValue::String(s) => {
2807            let _ = group.set_attr_string(attr_name, s);
2808        }
2809        NDAttrValue::Undefined => {}
2810    }
2811}
2812
2813/// Write a live NDAttribute value as an HDF5 attribute on a standard-mode
2814/// dataset, via a live dataset handle. rust-hdf5 0.2.17 cannot reopen a
2815/// dataset by name while the file is in write mode (`H5File::dataset` errors
2816/// with "cannot open a dataset by name in write mode"), so the caller supplies
2817/// the handle obtained at create time. C `storeOnOpenCloseAttribute` dataset
2818/// branch; `Undefined` is skipped, errors are non-fatal.
2819fn write_ndattr_dataset_attr(ds: &rust_hdf5::H5Dataset, attr_name: &str, value: &NDAttrValue) {
2820    macro_rules! num {
2821        ($v:expr, $t:ty) => {{
2822            let v: $t = $v;
2823            let _ = ds
2824                .new_attr::<$t>()
2825                .shape(())
2826                .create(attr_name)
2827                .and_then(|a| a.write_numeric(&v));
2828        }};
2829    }
2830    match value {
2831        NDAttrValue::Int8(v) => num!(*v, i8),
2832        NDAttrValue::UInt8(v) => num!(*v, u8),
2833        NDAttrValue::Int16(v) => num!(*v, i16),
2834        NDAttrValue::UInt16(v) => num!(*v, u16),
2835        NDAttrValue::Int32(v) => num!(*v, i32),
2836        NDAttrValue::UInt32(v) => num!(*v, u32),
2837        NDAttrValue::Int64(v) => num!(*v, i64),
2838        NDAttrValue::UInt64(v) => num!(*v, u64),
2839        NDAttrValue::Float32(v) => num!(*v, f32),
2840        NDAttrValue::Float64(v) => num!(*v, f64),
2841        NDAttrValue::String(s) => {
2842            let sv = rust_hdf5::types::VarLenUnicode(s.clone());
2843            let _ = ds
2844                .new_attr::<rust_hdf5::types::VarLenUnicode>()
2845                .shape(())
2846                .create(attr_name)
2847                .and_then(|a| a.write_scalar(&sv));
2848        }
2849        NDAttrValue::Undefined => {}
2850    }
2851}
2852
2853/// SWMR counterpart of [`write_ndattr_element_attr`] for group element-attrs:
2854/// write a live NDAttribute value as a group HDF5 attribute, addressed by the
2855/// group's absolute path. The datatype follows the runtime NDAttribute value
2856/// (C `typeNd2Hdf`); `Undefined` is skipped.
2857fn write_swmr_ndattr_group_attr(
2858    swmr: &mut SwmrFileWriter,
2859    group_path: &str,
2860    attr_name: &str,
2861    value: &NDAttrValue,
2862) -> ADResult<()> {
2863    let res = match value {
2864        NDAttrValue::Int8(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2865        NDAttrValue::UInt8(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2866        NDAttrValue::Int16(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2867        NDAttrValue::UInt16(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2868        NDAttrValue::Int32(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2869        NDAttrValue::UInt32(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2870        NDAttrValue::Int64(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2871        NDAttrValue::UInt64(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2872        NDAttrValue::Float32(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2873        NDAttrValue::Float64(v) => swmr.set_group_attr_numeric(group_path, attr_name, v),
2874        NDAttrValue::String(s) => swmr.set_group_attr_string(group_path, attr_name, s),
2875        NDAttrValue::Undefined => return Ok(()),
2876    };
2877    res.map_err(|e| {
2878        ADError::UnsupportedConversion(format!(
2879            "SWMR ndattribute group attribute '{}/{}': {}",
2880            group_path, attr_name, e
2881        ))
2882    })
2883}
2884
2885/// SWMR counterpart of [`write_ndattr_dataset_attr`]: write a live NDAttribute
2886/// value as a dataset HDF5 attribute, addressed by the dataset's index (the
2887/// only handle `SwmrFileWriter` exposes for datasets). The datatype follows the
2888/// runtime NDAttribute value (C `typeNd2Hdf`); `Undefined` is skipped. Must be
2889/// called before `start_swmr()` — HDF5 forbids creating attributes after the
2890/// SWMR lock.
2891fn write_swmr_ndattr_dataset_attr(
2892    swmr: &mut SwmrFileWriter,
2893    ds_index: usize,
2894    attr_name: &str,
2895    value: &NDAttrValue,
2896) -> ADResult<()> {
2897    let res = match value {
2898        NDAttrValue::Int8(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2899        NDAttrValue::UInt8(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2900        NDAttrValue::Int16(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2901        NDAttrValue::UInt16(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2902        NDAttrValue::Int32(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2903        NDAttrValue::UInt32(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2904        NDAttrValue::Int64(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2905        NDAttrValue::UInt64(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2906        NDAttrValue::Float32(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2907        NDAttrValue::Float64(v) => swmr.set_dataset_attr_numeric(ds_index, attr_name, v),
2908        NDAttrValue::String(s) => swmr.set_dataset_attr_string(ds_index, attr_name, s),
2909        NDAttrValue::Undefined => return Ok(()),
2910    };
2911    res.map_err(|e| {
2912        ADError::UnsupportedConversion(format!(
2913            "SWMR ndattribute dataset attribute '{}': {}",
2914            attr_name, e
2915        ))
2916    })
2917}
2918
2919/// Write a layout group's `constant`-sourced `<attribute>` nodes (e.g. the
2920/// NeXus `NX_class` markers) to an open standard-mode group, typed per the XML
2921/// `type` attribute. `ndattribute`-sourced group attributes carry per-frame
2922/// values and are written separately by [`write_ndattr_group_attr`]. Errors are
2923/// non-fatal.
2924fn write_group_constant_attrs(
2925    group: &rust_hdf5::H5Group,
2926    attrs: &[crate::hdf5_layout::LayoutAttribute],
2927) {
2928    use crate::hdf5_layout::{LayoutDataType, LayoutSource};
2929    for a in attrs {
2930        if a.source != LayoutSource::Constant {
2931            continue;
2932        }
2933        let _ = match a.data_type {
2934            LayoutDataType::Int => {
2935                group.set_attr_numeric(&a.name, &a.value.trim().parse::<i64>().unwrap_or(0))
2936            }
2937            LayoutDataType::Float => {
2938                group.set_attr_numeric(&a.name, &a.value.trim().parse::<f64>().unwrap_or(0.0))
2939            }
2940            LayoutDataType::String => group.set_attr_string(&a.name, &a.value),
2941        };
2942    }
2943}
2944
2945/// SWMR counterpart of [`write_group_constant_attrs`]: write a layout group's
2946/// `constant` `<attribute>` nodes against a `SwmrFileWriter`, addressing the
2947/// group by its absolute path.
2948fn write_swmr_group_constant_attrs(
2949    swmr: &mut SwmrFileWriter,
2950    group_path: &str,
2951    attrs: &[crate::hdf5_layout::LayoutAttribute],
2952) -> ADResult<()> {
2953    use crate::hdf5_layout::{LayoutDataType, LayoutSource};
2954    for a in attrs {
2955        if a.source != LayoutSource::Constant {
2956            continue;
2957        }
2958        match a.data_type {
2959            LayoutDataType::Int => swmr.set_group_attr_numeric(
2960                group_path,
2961                &a.name,
2962                &a.value.trim().parse::<i64>().unwrap_or(0),
2963            ),
2964            LayoutDataType::Float => swmr.set_group_attr_numeric(
2965                group_path,
2966                &a.name,
2967                &a.value.trim().parse::<f64>().unwrap_or(0.0),
2968            ),
2969            LayoutDataType::String => swmr.set_group_attr_string(group_path, &a.name, &a.value),
2970        }
2971        .map_err(|e| {
2972            ADError::UnsupportedConversion(format!(
2973                "SWMR layout group attribute '{}/{}': {}",
2974                group_path, a.name, e
2975            ))
2976        })?;
2977    }
2978    Ok(())
2979}
2980
2981impl Default for Hdf5Writer {
2982    fn default() -> Self {
2983        Self::new()
2984    }
2985}
2986
2987impl NDFileWriter for Hdf5Writer {
2988    fn open_file(&mut self, path: &Path, mode: NDFileMode, array: &NDArray) -> ADResult<()> {
2989        self.current_path = Some(path.to_path_buf());
2990        self.open_mode = mode;
2991        self.frame_count = 0;
2992        self.total_runtime = 0.0;
2993        self.total_bytes = 0;
2994        self.swmr_cb_counter = 0;
2995        self.open_data_type = None;
2996        self.open_frame_dims = None;
2997        self.open_codec = None;
2998        self.perf_rows.clear();
2999        self.perf_prev = None;
3000        self.perf_first = None;
3001        self.attr_datasets.clear();
3002        // Resolve where image/attribute/performance datasets land for this
3003        // file: the loaded layout XML tree, or the flat root default.
3004        self.resolve_layout_paths();
3005        // Seed the open-time NDAttribute values for any layout
3006        // `<attribute source="ndattribute">` element-attrs (ADP-79).
3007        self.seed_ndattr_element_values(array);
3008
3009        if self.swmr_mode && mode == NDFileMode::Stream {
3010            self.open_swmr(path, array)
3011        } else {
3012            let h5file = H5File::create(path)
3013                .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 create error: {}", e)))?;
3014            self.handle = Some(Hdf5Handle::Standard {
3015                file: h5file,
3016                detectors: std::collections::HashMap::new(),
3017            });
3018            Ok(())
3019        }
3020    }
3021
3022    fn set_num_capture(&mut self, n: usize) {
3023        self.num_capture = n;
3024    }
3025
3026    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
3027        let start = std::time::Instant::now();
3028
3029        let is_swmr = matches!(self.handle, Some(Hdf5Handle::Swmr { .. }));
3030        if is_swmr {
3031            self.write_swmr(array)?;
3032        } else {
3033            self.write_standard(array)?;
3034        }
3035        // Track the latest NDAttribute values for `when="OnFileClose"`
3036        // element-attrs (ADP-79); no-op unless the layout declares any.
3037        self.update_ndattr_element_values(array);
3038        self.frame_count += 1;
3039
3040        let elapsed = start.elapsed().as_secs_f64();
3041        let frame_bytes = array.data.as_u8_slice().len();
3042        if self.store_performance {
3043            self.total_runtime += elapsed;
3044            self.total_bytes += frame_bytes as u64;
3045            self.record_performance(elapsed, frame_bytes);
3046        }
3047        Ok(())
3048    }
3049
3050    fn read_file(&mut self) -> ADResult<NDArray> {
3051        // The image dataset lives at the layout-resolved path (flat `data`
3052        // by default, or the nested layout path). Resolve it so read-back
3053        // tracks the same placement as the write path.
3054        self.resolve_layout_paths();
3055        let dataset_path = self.resolved_dataset_path.clone();
3056        let path = self
3057            .current_path
3058            .as_ref()
3059            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
3060
3061        let h5file = H5File::open(path)
3062            .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 open error: {}", e)))?;
3063
3064        let ds = h5file
3065            .dataset(&dataset_path)
3066            .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 dataset error: {}", e)))?;
3067
3068        let shape = ds.shape();
3069        let dims: Vec<NDDimension> = shape.iter().rev().map(|&s| NDDimension::new(s)).collect();
3070        let element_size = ds.element_size();
3071
3072        // Prefer the exact data type recorded at write time.
3073        let recorded: Option<NDDataType> = ds
3074            .attr(DTYPE_ATTR)
3075            .ok()
3076            .and_then(|a| a.read_numeric::<i32>().ok())
3077            .and_then(|v| NDDataType::from_ordinal(v as u8));
3078
3079        let data_type = recorded.unwrap_or(match element_size {
3080            1 => NDDataType::UInt8,
3081            2 => NDDataType::UInt16,
3082            4 => NDDataType::Float32,
3083            8 => NDDataType::Float64,
3084            other => {
3085                return Err(ADError::UnsupportedConversion(format!(
3086                    "unsupported HDF5 element size {}",
3087                    other
3088                )));
3089            }
3090        });
3091
3092        macro_rules! read_typed {
3093            ($t:ty, $variant:ident) => {{
3094                let data = ds.read_raw::<$t>().map_err(|e| {
3095                    ADError::UnsupportedConversion(format!("HDF5 read error: {}", e))
3096                })?;
3097                let mut arr = NDArray::new(dims, data_type);
3098                arr.data = NDDataBuffer::$variant(data);
3099                return Ok(arr);
3100            }};
3101        }
3102
3103        match data_type {
3104            NDDataType::Int8 => read_typed!(i8, I8),
3105            NDDataType::UInt8 => read_typed!(u8, U8),
3106            NDDataType::Int16 => read_typed!(i16, I16),
3107            NDDataType::UInt16 => read_typed!(u16, U16),
3108            NDDataType::Int32 => read_typed!(i32, I32),
3109            NDDataType::UInt32 => read_typed!(u32, U32),
3110            NDDataType::Int64 => read_typed!(i64, I64),
3111            NDDataType::UInt64 => read_typed!(u64, U64),
3112            NDDataType::Float32 => read_typed!(f32, F32),
3113            NDDataType::Float64 => read_typed!(f64, F64),
3114        }
3115    }
3116
3117    fn close_file(&mut self) -> ADResult<()> {
3118        match self.handle {
3119            Some(Hdf5Handle::Standard { .. }) => {
3120                // Flush each detector dataset's partial frame band and trim its
3121                // extent, then emit the accumulated attribute and performance
3122                // datasets before the file is finalised.
3123                self.finalize_standard_datasets()?;
3124                self.flush_attribute_datasets()?;
3125                self.flush_performance_dataset()?;
3126                // Materialise layout `<hardlink>` elements last, once every
3127                // dataset a link may target exists on disk.
3128                match self.handle {
3129                    Some(Hdf5Handle::Standard {
3130                        ref file,
3131                        ref detectors,
3132                    }) => {
3133                        self.build_layout_hardlinks(file)?;
3134                        // Every group and dataset now exists, so layout
3135                        // `<attribute source="ndattribute">` element-attrs can
3136                        // be attached with their open/close values (ADP-79).
3137                        self.flush_ndattr_element_attrs(file, detectors)?;
3138                    }
3139                    _ => unreachable!("handle is Standard in this arm"),
3140                }
3141                self.handle = None;
3142            }
3143            Some(Hdf5Handle::Swmr { .. }) => {
3144                // The layout group tree, the nested dataset placement and the
3145                // layout `<hardlink>` elements were all materialised in
3146                // `open_swmr` before `start_swmr()` (C `NDFileHDF5.cpp:320`-
3147                // `326`: `createHardLinks` then `startSWMR`), so SWMR readers
3148                // see them for the whole streaming window. Closing the writer
3149                // only finalises the streamed frames.
3150                if let Some(Hdf5Handle::Swmr { mut writer, .. }) = self.handle.take() {
3151                    // Commit any chunk writes made since the last periodic flush
3152                    // before closing. `SwmrFileWriter::close` finalizes the
3153                    // extensible-array index of the `append_frame` path but does
3154                    // not flush the fixed-array index that the grid path's
3155                    // `write_chunk_at` records, so a grid file's tail frames
3156                    // would otherwise read back as fill.
3157                    writer.flush().map_err(|e| {
3158                        ADError::UnsupportedConversion(format!("SWMR flush-on-close error: {}", e))
3159                    })?;
3160                    writer.close().map_err(|e| {
3161                        ADError::UnsupportedConversion(format!("SWMR close error: {}", e))
3162                    })?;
3163                }
3164            }
3165            None => {}
3166        }
3167        self.current_path = None;
3168        Ok(())
3169    }
3170
3171    fn supports_multiple_arrays(&self) -> bool {
3172        true
3173    }
3174}
3175
3176// ============================================================
3177// Processor
3178// ============================================================
3179
3180/// Param indices for HDF5-specific params.
3181#[derive(Default)]
3182struct Hdf5ParamIndices {
3183    compression_type: Option<usize>,
3184    z_compress_level: Option<usize>,
3185    szip_num_pixels: Option<usize>,
3186    nbit_precision: Option<usize>,
3187    nbit_offset: Option<usize>,
3188    jpeg_quality: Option<usize>,
3189    blosc_shuffle_type: Option<usize>,
3190    blosc_compressor: Option<usize>,
3191    blosc_compress_level: Option<usize>,
3192    store_attributes: Option<usize>,
3193    store_performance: Option<usize>,
3194    total_runtime: Option<usize>,
3195    total_io_speed: Option<usize>,
3196    swmr_mode: Option<usize>,
3197    swmr_flush_now: Option<usize>,
3198    swmr_running: Option<usize>,
3199    swmr_cb_counter: Option<usize>,
3200    swmr_supported: Option<usize>,
3201    flush_nth_frame: Option<usize>,
3202    chunk_size_auto: Option<usize>,
3203    n_row_chunks: Option<usize>,
3204    n_col_chunks: Option<usize>,
3205    n_frames_chunks: Option<usize>,
3206    ndattr_chunk: Option<usize>,
3207    n_extra_dims: Option<usize>,
3208    extra_dim_size: [Option<usize>; MAX_EXTRA_DIMS],
3209    extra_dim_name: [Option<usize>; MAX_EXTRA_DIMS],
3210    fill_value: Option<usize>,
3211    dim_att_datasets: Option<usize>,
3212    layout_filename: Option<usize>,
3213    layout_valid: Option<usize>,
3214    layout_error_msg: Option<usize>,
3215}
3216
3217/// HDF5 file processor wrapping FilePluginController<Hdf5Writer>.
3218pub struct Hdf5FileProcessor {
3219    ctrl: FilePluginController<Hdf5Writer>,
3220    hdf5_params: Hdf5ParamIndices,
3221}
3222
3223impl Hdf5FileProcessor {
3224    pub fn new() -> Self {
3225        Self {
3226            ctrl: FilePluginController::new(Hdf5Writer::new()),
3227            hdf5_params: Hdf5ParamIndices::default(),
3228        }
3229    }
3230
3231    pub fn set_dataset_name(&mut self, name: &str) {
3232        self.ctrl.writer.set_dataset_name(name);
3233    }
3234}
3235
3236/// Register all HDF5-specific params.
3237fn register_hdf5_params(
3238    base: &mut asyn_rs::port::PortDriverBase,
3239) -> asyn_rs::error::AsynResult<()> {
3240    use asyn_rs::param::ParamType;
3241    base.create_param("HDF5_SWMRFlushNow", ParamType::Int32)?;
3242    base.create_param("HDF5_chunkSizeAuto", ParamType::Int32)?;
3243    base.create_param("HDF5_nRowChunks", ParamType::Int32)?;
3244    base.create_param("HDF5_nColChunks", ParamType::Int32)?;
3245    base.create_param("HDF5_chunkSize2", ParamType::Int32)?;
3246    base.create_param("HDF5_chunkSize3", ParamType::Int32)?;
3247    base.create_param("HDF5_chunkSize4", ParamType::Int32)?;
3248    base.create_param("HDF5_chunkSize5", ParamType::Int32)?;
3249    base.create_param("HDF5_chunkSize6", ParamType::Int32)?;
3250    base.create_param("HDF5_chunkSize7", ParamType::Int32)?;
3251    base.create_param("HDF5_chunkSize8", ParamType::Int32)?;
3252    base.create_param("HDF5_chunkSize9", ParamType::Int32)?;
3253    base.create_param("HDF5_nFramesChunks", ParamType::Int32)?;
3254    base.create_param("HDF5_NDAttributeChunk", ParamType::Int32)?;
3255    base.create_param("HDF5_chunkBoundaryAlign", ParamType::Int32)?;
3256    base.create_param("HDF5_chunkBoundaryThreshold", ParamType::Int32)?;
3257    base.create_param("HDF5_nExtraDims", ParamType::Int32)?;
3258    base.create_param("HDF5_extraDimSizeN", ParamType::Int32)?;
3259    base.create_param("HDF5_extraDimNameN", ParamType::Octet)?;
3260    base.create_param("HDF5_extraDimSizeX", ParamType::Int32)?;
3261    base.create_param("HDF5_extraDimNameX", ParamType::Octet)?;
3262    base.create_param("HDF5_extraDimSizeY", ParamType::Int32)?;
3263    base.create_param("HDF5_extraDimNameY", ParamType::Octet)?;
3264    base.create_param("HDF5_extraDimSize3", ParamType::Int32)?;
3265    base.create_param("HDF5_extraDimName3", ParamType::Octet)?;
3266    base.create_param("HDF5_extraDimSize4", ParamType::Int32)?;
3267    base.create_param("HDF5_extraDimName4", ParamType::Octet)?;
3268    base.create_param("HDF5_extraDimSize5", ParamType::Int32)?;
3269    base.create_param("HDF5_extraDimName5", ParamType::Octet)?;
3270    base.create_param("HDF5_extraDimSize6", ParamType::Int32)?;
3271    base.create_param("HDF5_extraDimName6", ParamType::Octet)?;
3272    base.create_param("HDF5_extraDimSize7", ParamType::Int32)?;
3273    base.create_param("HDF5_extraDimName7", ParamType::Octet)?;
3274    base.create_param("HDF5_extraDimSize8", ParamType::Int32)?;
3275    base.create_param("HDF5_extraDimName8", ParamType::Octet)?;
3276    base.create_param("HDF5_extraDimSize9", ParamType::Int32)?;
3277    base.create_param("HDF5_extraDimName9", ParamType::Octet)?;
3278    base.create_param("HDF5_storeAttributes", ParamType::Int32)?;
3279    base.create_param("HDF5_storePerformance", ParamType::Int32)?;
3280    base.create_param("HDF5_totalRuntime", ParamType::Float64)?;
3281    base.create_param("HDF5_totalIoSpeed", ParamType::Float64)?;
3282    base.create_param("HDF5_flushNthFrame", ParamType::Int32)?;
3283    base.create_param("HDF5_compressionType", ParamType::Int32)?;
3284    base.create_param("HDF5_nbitsPrecision", ParamType::Int32)?;
3285    base.create_param("HDF5_nbitsOffset", ParamType::Int32)?;
3286    base.create_param("HDF5_szipNumPixels", ParamType::Int32)?;
3287    base.create_param("HDF5_zCompressLevel", ParamType::Int32)?;
3288    base.create_param("HDF5_bloscShuffleType", ParamType::Int32)?;
3289    base.create_param("HDF5_bloscCompressor", ParamType::Int32)?;
3290    base.create_param("HDF5_bloscCompressLevel", ParamType::Int32)?;
3291    base.create_param("HDF5_jpegQuality", ParamType::Int32)?;
3292    base.create_param("HDF5_dimAttDatasets", ParamType::Int32)?;
3293    base.create_param("HDF5_layoutErrorMsg", ParamType::Octet)?;
3294    base.create_param("HDF5_layoutValid", ParamType::Int32)?;
3295    base.create_param("HDF5_layoutFilename", ParamType::Octet)?;
3296    base.create_param("HDF5_SWMRSupported", ParamType::Int32)?;
3297    base.create_param("HDF5_SWMRMode", ParamType::Int32)?;
3298    base.create_param("HDF5_SWMRRunning", ParamType::Int32)?;
3299    base.create_param("HDF5_SWMRCbCounter", ParamType::Int32)?;
3300    base.create_param("HDF5_posRunning", ParamType::Int32)?;
3301    base.create_param("HDF5_posNameDimN", ParamType::Octet)?;
3302    base.create_param("HDF5_posNameDimX", ParamType::Octet)?;
3303    base.create_param("HDF5_posNameDimY", ParamType::Octet)?;
3304    base.create_param("HDF5_posNameDim3", ParamType::Octet)?;
3305    base.create_param("HDF5_posNameDim4", ParamType::Octet)?;
3306    base.create_param("HDF5_posNameDim5", ParamType::Octet)?;
3307    base.create_param("HDF5_posNameDim6", ParamType::Octet)?;
3308    base.create_param("HDF5_posNameDim7", ParamType::Octet)?;
3309    base.create_param("HDF5_posNameDim8", ParamType::Octet)?;
3310    base.create_param("HDF5_posNameDim9", ParamType::Octet)?;
3311    base.create_param("HDF5_posIndexDimN", ParamType::Octet)?;
3312    base.create_param("HDF5_posIndexDimX", ParamType::Octet)?;
3313    base.create_param("HDF5_posIndexDimY", ParamType::Octet)?;
3314    base.create_param("HDF5_posIndexDim3", ParamType::Octet)?;
3315    base.create_param("HDF5_posIndexDim4", ParamType::Octet)?;
3316    base.create_param("HDF5_posIndexDim5", ParamType::Octet)?;
3317    base.create_param("HDF5_posIndexDim6", ParamType::Octet)?;
3318    base.create_param("HDF5_posIndexDim7", ParamType::Octet)?;
3319    base.create_param("HDF5_posIndexDim8", ParamType::Octet)?;
3320    base.create_param("HDF5_posIndexDim9", ParamType::Octet)?;
3321    base.create_param("HDF5_fillValue", ParamType::Float64)?;
3322    base.create_param("HDF5_extraDimChunkX", ParamType::Int32)?;
3323    base.create_param("HDF5_extraDimChunkY", ParamType::Int32)?;
3324    base.create_param("HDF5_extraDimChunk3", ParamType::Int32)?;
3325    base.create_param("HDF5_extraDimChunk4", ParamType::Int32)?;
3326    base.create_param("HDF5_extraDimChunk5", ParamType::Int32)?;
3327    base.create_param("HDF5_extraDimChunk6", ParamType::Int32)?;
3328    base.create_param("HDF5_extraDimChunk7", ParamType::Int32)?;
3329    base.create_param("HDF5_extraDimChunk8", ParamType::Int32)?;
3330    base.create_param("HDF5_extraDimChunk9", ParamType::Int32)?;
3331    Ok(())
3332}
3333
3334impl Default for Hdf5FileProcessor {
3335    fn default() -> Self {
3336        Self::new()
3337    }
3338}
3339
3340/// Names of the `HDF5_extraDimSizeN..9` params in slot order.
3341const EXTRA_DIM_SIZE_PARAMS: [&str; MAX_EXTRA_DIMS] = [
3342    "HDF5_extraDimSizeN",
3343    "HDF5_extraDimSizeX",
3344    "HDF5_extraDimSizeY",
3345    "HDF5_extraDimSize3",
3346    "HDF5_extraDimSize4",
3347    "HDF5_extraDimSize5",
3348    "HDF5_extraDimSize6",
3349    "HDF5_extraDimSize7",
3350    "HDF5_extraDimSize8",
3351    "HDF5_extraDimSize9",
3352];
3353
3354/// Names of the `HDF5_extraDimNameN..9` params in slot order.
3355const EXTRA_DIM_NAME_PARAMS: [&str; MAX_EXTRA_DIMS] = [
3356    "HDF5_extraDimNameN",
3357    "HDF5_extraDimNameX",
3358    "HDF5_extraDimNameY",
3359    "HDF5_extraDimName3",
3360    "HDF5_extraDimName4",
3361    "HDF5_extraDimName5",
3362    "HDF5_extraDimName6",
3363    "HDF5_extraDimName7",
3364    "HDF5_extraDimName8",
3365    "HDF5_extraDimName9",
3366];
3367
3368impl NDPluginProcess for Hdf5FileProcessor {
3369    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
3370        let was_swmr = self.ctrl.writer.is_swmr_active();
3371        let mut result = self.ctrl.process_array(array);
3372        let is_swmr = self.ctrl.writer.is_swmr_active();
3373
3374        // SWMR running status changed
3375        if was_swmr != is_swmr {
3376            if let Some(idx) = self.hdf5_params.swmr_running {
3377                result
3378                    .param_updates
3379                    .push(ParamUpdate::int32(idx, if is_swmr { 1 } else { 0 }));
3380            }
3381        }
3382
3383        // SWMR callback counter
3384        if is_swmr {
3385            if let Some(idx) = self.hdf5_params.swmr_cb_counter {
3386                result.param_updates.push(ParamUpdate::int32(
3387                    idx,
3388                    self.ctrl.writer.swmr_cb_counter as i32,
3389                ));
3390            }
3391        }
3392
3393        // Performance stats
3394        if self.ctrl.writer.store_performance {
3395            if let Some(idx) = self.hdf5_params.total_runtime {
3396                result
3397                    .param_updates
3398                    .push(ParamUpdate::float64(idx, self.ctrl.writer.total_runtime));
3399            }
3400            if let Some(idx) = self.hdf5_params.total_io_speed {
3401                let speed = if self.ctrl.writer.total_runtime > 0.0 {
3402                    self.ctrl.writer.total_bytes as f64
3403                        / self.ctrl.writer.total_runtime
3404                        / 1_000_000.0
3405                } else {
3406                    0.0
3407                };
3408                result.param_updates.push(ParamUpdate::float64(idx, speed));
3409            }
3410        }
3411
3412        result
3413    }
3414
3415    fn plugin_type(&self) -> &str {
3416        "NDFileHDF5"
3417    }
3418
3419    /// C `NDFileHDF5` passes `compressionAware = true` to the file driver
3420    /// (NDFileHDF5.cpp:2268): it accepts pre-compressed input arrays and writes
3421    /// their bytes verbatim through a matching HDF5 filter via direct chunk
3422    /// write, rather than having the framework decompress them first. The
3423    /// standard (non-SWMR) write path implements this; see `write_standard`.
3424    fn compression_aware(&self) -> bool {
3425        true
3426    }
3427
3428    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
3429    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
3430    fn does_array_callbacks(&self) -> bool {
3431        false
3432    }
3433
3434    fn register_params(
3435        &mut self,
3436        base: &mut asyn_rs::port::PortDriverBase,
3437    ) -> asyn_rs::error::AsynResult<()> {
3438        self.ctrl.register_params(base)?;
3439        register_hdf5_params(base)?;
3440        self.hdf5_params.compression_type = base.find_param("HDF5_compressionType");
3441        self.hdf5_params.z_compress_level = base.find_param("HDF5_zCompressLevel");
3442        self.hdf5_params.szip_num_pixels = base.find_param("HDF5_szipNumPixels");
3443        self.hdf5_params.nbit_precision = base.find_param("HDF5_nbitsPrecision");
3444        self.hdf5_params.nbit_offset = base.find_param("HDF5_nbitsOffset");
3445        self.hdf5_params.jpeg_quality = base.find_param("HDF5_jpegQuality");
3446        self.hdf5_params.blosc_shuffle_type = base.find_param("HDF5_bloscShuffleType");
3447        self.hdf5_params.blosc_compressor = base.find_param("HDF5_bloscCompressor");
3448        self.hdf5_params.blosc_compress_level = base.find_param("HDF5_bloscCompressLevel");
3449        self.hdf5_params.store_attributes = base.find_param("HDF5_storeAttributes");
3450        self.hdf5_params.store_performance = base.find_param("HDF5_storePerformance");
3451        self.hdf5_params.total_runtime = base.find_param("HDF5_totalRuntime");
3452        self.hdf5_params.total_io_speed = base.find_param("HDF5_totalIoSpeed");
3453        self.hdf5_params.swmr_mode = base.find_param("HDF5_SWMRMode");
3454        self.hdf5_params.swmr_flush_now = base.find_param("HDF5_SWMRFlushNow");
3455        self.hdf5_params.swmr_running = base.find_param("HDF5_SWMRRunning");
3456        self.hdf5_params.swmr_cb_counter = base.find_param("HDF5_SWMRCbCounter");
3457        self.hdf5_params.swmr_supported = base.find_param("HDF5_SWMRSupported");
3458        self.hdf5_params.flush_nth_frame = base.find_param("HDF5_flushNthFrame");
3459        self.hdf5_params.chunk_size_auto = base.find_param("HDF5_chunkSizeAuto");
3460        self.hdf5_params.n_row_chunks = base.find_param("HDF5_nRowChunks");
3461        self.hdf5_params.n_col_chunks = base.find_param("HDF5_nColChunks");
3462        self.hdf5_params.n_frames_chunks = base.find_param("HDF5_nFramesChunks");
3463        self.hdf5_params.ndattr_chunk = base.find_param("HDF5_NDAttributeChunk");
3464        self.hdf5_params.n_extra_dims = base.find_param("HDF5_nExtraDims");
3465        for i in 0..MAX_EXTRA_DIMS {
3466            self.hdf5_params.extra_dim_size[i] = base.find_param(EXTRA_DIM_SIZE_PARAMS[i]);
3467            self.hdf5_params.extra_dim_name[i] = base.find_param(EXTRA_DIM_NAME_PARAMS[i]);
3468        }
3469        self.hdf5_params.fill_value = base.find_param("HDF5_fillValue");
3470        self.hdf5_params.dim_att_datasets = base.find_param("HDF5_dimAttDatasets");
3471        self.hdf5_params.layout_filename = base.find_param("HDF5_layoutFilename");
3472        self.hdf5_params.layout_valid = base.find_param("HDF5_layoutValid");
3473        self.hdf5_params.layout_error_msg = base.find_param("HDF5_layoutErrorMsg");
3474
3475        // Report SWMR as always supported
3476        if let Some(idx) = self.hdf5_params.swmr_supported {
3477            base.set_int32_param(idx, 0, 1)?;
3478        }
3479        Ok(())
3480    }
3481
3482    fn on_param_change(
3483        &mut self,
3484        reason: usize,
3485        params: &PluginParamSnapshot,
3486    ) -> ParamChangeResult {
3487        // -- compression params --
3488        if Some(reason) == self.hdf5_params.compression_type {
3489            self.ctrl.writer.set_compression_type(params.value.as_i32());
3490            return ParamChangeResult::updates(vec![]);
3491        }
3492        if Some(reason) == self.hdf5_params.z_compress_level {
3493            self.ctrl
3494                .writer
3495                .set_z_compress_level(params.value.as_i32() as u32);
3496            return ParamChangeResult::updates(vec![]);
3497        }
3498        if Some(reason) == self.hdf5_params.szip_num_pixels {
3499            self.ctrl
3500                .writer
3501                .set_szip_num_pixels(params.value.as_i32() as u32);
3502            return ParamChangeResult::updates(vec![]);
3503        }
3504        if Some(reason) == self.hdf5_params.blosc_shuffle_type {
3505            self.ctrl
3506                .writer
3507                .set_blosc_shuffle_type(params.value.as_i32());
3508            return ParamChangeResult::updates(vec![]);
3509        }
3510        if Some(reason) == self.hdf5_params.blosc_compressor {
3511            self.ctrl.writer.set_blosc_compressor(params.value.as_i32());
3512            return ParamChangeResult::updates(vec![]);
3513        }
3514        if Some(reason) == self.hdf5_params.blosc_compress_level {
3515            self.ctrl
3516                .writer
3517                .set_blosc_compress_level(params.value.as_i32() as u32);
3518            return ParamChangeResult::updates(vec![]);
3519        }
3520        if Some(reason) == self.hdf5_params.nbit_precision {
3521            self.ctrl
3522                .writer
3523                .set_nbit_precision(params.value.as_i32() as u32);
3524            return ParamChangeResult::updates(vec![]);
3525        }
3526        if Some(reason) == self.hdf5_params.nbit_offset {
3527            self.ctrl
3528                .writer
3529                .set_nbit_offset(params.value.as_i32() as u32);
3530            return ParamChangeResult::updates(vec![]);
3531        }
3532        if Some(reason) == self.hdf5_params.jpeg_quality {
3533            self.ctrl
3534                .writer
3535                .set_jpeg_quality(params.value.as_i32() as u32);
3536            return ParamChangeResult::updates(vec![]);
3537        }
3538        if Some(reason) == self.hdf5_params.store_attributes {
3539            self.ctrl
3540                .writer
3541                .set_store_attributes(params.value.as_i32() != 0);
3542            return ParamChangeResult::updates(vec![]);
3543        }
3544        if Some(reason) == self.hdf5_params.store_performance {
3545            self.ctrl
3546                .writer
3547                .set_store_performance(params.value.as_i32() != 0);
3548            return ParamChangeResult::updates(vec![]);
3549        }
3550        // -- chunking params --
3551        if Some(reason) == self.hdf5_params.chunk_size_auto {
3552            self.ctrl
3553                .writer
3554                .set_chunk_size_auto(params.value.as_i32() != 0);
3555            return ParamChangeResult::updates(vec![]);
3556        }
3557        if Some(reason) == self.hdf5_params.n_row_chunks {
3558            self.ctrl
3559                .writer
3560                .set_n_row_chunks(params.value.as_i32().max(0) as usize);
3561            return ParamChangeResult::updates(vec![]);
3562        }
3563        if Some(reason) == self.hdf5_params.n_col_chunks {
3564            self.ctrl
3565                .writer
3566                .set_n_col_chunks(params.value.as_i32().max(0) as usize);
3567            return ParamChangeResult::updates(vec![]);
3568        }
3569        if Some(reason) == self.hdf5_params.n_frames_chunks {
3570            self.ctrl
3571                .writer
3572                .set_n_frames_chunks(params.value.as_i32().max(0) as usize);
3573            return ParamChangeResult::updates(vec![]);
3574        }
3575        if Some(reason) == self.hdf5_params.ndattr_chunk {
3576            // `0` (auto) is valid; only negatives are coerced away.
3577            self.ctrl
3578                .writer
3579                .set_ndattr_chunk(params.value.as_i32().max(0) as usize);
3580            return ParamChangeResult::updates(vec![]);
3581        }
3582        // -- extra dimensions --
3583        if Some(reason) == self.hdf5_params.n_extra_dims {
3584            self.ctrl
3585                .writer
3586                .set_n_extra_dims(params.value.as_i32().max(0) as usize);
3587            return ParamChangeResult::updates(vec![]);
3588        }
3589        for i in 0..MAX_EXTRA_DIMS {
3590            if Some(reason) == self.hdf5_params.extra_dim_size[i] {
3591                self.ctrl
3592                    .writer
3593                    .set_extra_dim_size(i, params.value.as_i32().max(1) as usize);
3594                return ParamChangeResult::updates(vec![]);
3595            }
3596            if Some(reason) == self.hdf5_params.extra_dim_name[i] {
3597                self.ctrl
3598                    .writer
3599                    .set_extra_dim_name(i, params.value.as_string().unwrap_or(""));
3600                return ParamChangeResult::updates(vec![]);
3601            }
3602        }
3603        if Some(reason) == self.hdf5_params.fill_value {
3604            self.ctrl.writer.set_fill_value(params.value.as_f64());
3605            return ParamChangeResult::updates(vec![]);
3606        }
3607        if Some(reason) == self.hdf5_params.dim_att_datasets {
3608            self.ctrl
3609                .writer
3610                .set_dim_att_datasets(params.value.as_i32() != 0);
3611            return ParamChangeResult::updates(vec![]);
3612        }
3613        // -- layout XML --
3614        if Some(reason) == self.hdf5_params.layout_filename {
3615            let path = params.value.as_string().unwrap_or("").to_string();
3616            self.ctrl.writer.set_layout_filename(&path);
3617            let mut updates = vec![];
3618            if let Some(idx) = self.hdf5_params.layout_valid {
3619                updates.push(ParamUpdate::int32(
3620                    idx,
3621                    if self.ctrl.writer.layout_valid { 1 } else { 0 },
3622                ));
3623            }
3624            if let Some(idx) = self.hdf5_params.layout_error_msg {
3625                updates.push(ParamUpdate::Octet {
3626                    reason: idx,
3627                    addr: 0,
3628                    value: self.ctrl.writer.layout_error.clone(),
3629                });
3630            }
3631            return ParamChangeResult::updates(updates);
3632        }
3633        // -- SWMR params --
3634        if Some(reason) == self.hdf5_params.swmr_mode {
3635            self.ctrl.writer.set_swmr_mode(params.value.as_i32() != 0);
3636            return ParamChangeResult::updates(vec![]);
3637        }
3638        if Some(reason) == self.hdf5_params.swmr_flush_now {
3639            if params.value.as_i32() != 0 {
3640                self.ctrl.writer.flush_swmr();
3641                let mut updates = vec![];
3642                if let Some(idx) = self.hdf5_params.swmr_cb_counter {
3643                    updates.push(ParamUpdate::int32(
3644                        idx,
3645                        self.ctrl.writer.swmr_cb_counter as i32,
3646                    ));
3647                }
3648                return ParamChangeResult::updates(updates);
3649            }
3650            return ParamChangeResult::updates(vec![]);
3651        }
3652        if Some(reason) == self.hdf5_params.flush_nth_frame {
3653            self.ctrl
3654                .writer
3655                .set_flush_nth_frame(params.value.as_i32().max(0) as usize);
3656            return ParamChangeResult::updates(vec![]);
3657        }
3658        self.ctrl.on_param_change(reason, params)
3659    }
3660}
3661
3662#[cfg(test)]
3663mod tests {
3664    use super::*;
3665    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
3666    use rust_hdf5::format::messages::filter::FILTER_LZ4;
3667    use std::sync::atomic::{AtomicU32, Ordering};
3668
3669    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
3670
3671    fn temp_path(prefix: &str) -> PathBuf {
3672        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
3673        std::env::temp_dir().join(format!("adcore_test_{}_{}.h5", prefix, n))
3674    }
3675
3676    #[test]
3677    fn test_write_single_frame() {
3678        let path = temp_path("hdf5_single");
3679        let mut writer = Hdf5Writer::new();
3680
3681        let mut arr = NDArray::new(
3682            vec![NDDimension::new(4), NDDimension::new(4)],
3683            NDDataType::UInt8,
3684        );
3685        if let NDDataBuffer::U8(ref mut v) = arr.data {
3686            for i in 0..16 {
3687                v[i] = i as u8;
3688            }
3689        }
3690
3691        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
3692        writer.write_file(&arr).unwrap();
3693        writer.close_file().unwrap();
3694
3695        // Single-frame standard mode: dataset is [1, 4, 4].
3696        let h5 = H5File::open(&path).unwrap();
3697        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3698        assert_eq!(ds.shape(), vec![1, 4, 4]);
3699        let data: Vec<u8> = ds.read_raw().unwrap();
3700        assert_eq!(data[0], 0);
3701        assert_eq!(data[15], 15);
3702        drop(h5);
3703
3704        let mut reader = Hdf5Writer::new();
3705        reader.current_path = Some(path.clone());
3706        let read_arr = reader.read_file().unwrap();
3707        assert_eq!(read_arr.dims.len(), 3);
3708        assert_eq!(read_arr.dims[2].size, 1); // leading frame dim
3709
3710        std::fs::remove_file(&path).ok();
3711    }
3712
3713    #[test]
3714    fn test_write_multiple_frames() {
3715        let path = temp_path("hdf5_multi");
3716        let mut writer = Hdf5Writer::new();
3717
3718        let mut arr = NDArray::new(
3719            vec![NDDimension::new(4), NDDimension::new(4)],
3720            NDDataType::UInt8,
3721        );
3722        // Mark each frame distinctly so we can verify per-frame placement.
3723        for f in 0..3u8 {
3724            if let NDDataBuffer::U8(ref mut v) = arr.data {
3725                for x in v.iter_mut() {
3726                    *x = f;
3727                }
3728            }
3729            if f == 0 {
3730                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3731            }
3732            writer.write_file(&arr).unwrap();
3733        }
3734        writer.close_file().unwrap();
3735
3736        assert!(writer.supports_multiple_arrays());
3737        assert_eq!(writer.frame_count(), 3);
3738
3739        let data = std::fs::read(&path).unwrap();
3740        assert_eq!(&data[0..8], b"\x89HDF\r\n\x1a\n");
3741
3742        // Single extensible dataset [3, 4, 4] — NOT one dataset per frame.
3743        let h5 = H5File::open(&path).unwrap();
3744        let names = h5.dataset_names();
3745        assert!(names.contains(&"entry/instrument/detector/data".to_string()));
3746        assert!(
3747            !names.contains(&"data_1".to_string()),
3748            "must not write per-frame datasets"
3749        );
3750        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3751        assert_eq!(
3752            ds.shape(),
3753            vec![3, 4, 4],
3754            "rank/shape must be [nframes,Y,X]"
3755        );
3756        let raw: Vec<u8> = ds.read_raw().unwrap();
3757        assert_eq!(raw.len(), 3 * 4 * 4);
3758        // Frame 0 all zeros, frame 1 all ones, frame 2 all twos.
3759        assert_eq!(raw[0], 0);
3760        assert_eq!(raw[16], 1);
3761        assert_eq!(raw[32], 2);
3762
3763        std::fs::remove_file(&path).ok();
3764    }
3765
3766    #[test]
3767    fn test_sub_frame_chunking() {
3768        // nRowChunks/nColChunks that divide the frame produce a sub-frame
3769        // chunk grid written via write_chunk_at tiles; the dataset shape
3770        // stays exactly [N, Y, X] (no padding) and the data round-trips.
3771        let path = temp_path("hdf5_subchunk");
3772        let mut writer = Hdf5Writer::new();
3773        writer.set_chunk_size_auto(false); // honor explicit chunk sizes
3774        writer.set_n_row_chunks(4); // Y = 8 → 2 row tiles
3775        writer.set_n_col_chunks(4); // X = 8 → 2 col tiles
3776
3777        let mut arr = NDArray::new(
3778            vec![NDDimension::new(8), NDDimension::new(8)],
3779            NDDataType::UInt16,
3780        );
3781        for f in 0..3u16 {
3782            if let NDDataBuffer::U16(ref mut v) = arr.data {
3783                for (i, x) in v.iter_mut().enumerate() {
3784                    *x = f * 1000 + i as u16;
3785                }
3786            }
3787            if f == 0 {
3788                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3789            }
3790            writer.write_file(&arr).unwrap();
3791        }
3792        writer.close_file().unwrap();
3793
3794        let h5 = H5File::open(&path).unwrap();
3795        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3796        assert_eq!(ds.shape(), vec![3, 8, 8], "shape must not be chunk-padded");
3797        assert_eq!(
3798            ds.chunk_dims(),
3799            Some(vec![1, 4, 4]),
3800            "chunk grid must be the sub-frame tile size"
3801        );
3802        let raw: Vec<u16> = ds.read_raw().unwrap();
3803        assert_eq!(raw.len(), 3 * 64);
3804        for f in 0..3u16 {
3805            for i in 0..64usize {
3806                assert_eq!(
3807                    raw[f as usize * 64 + i],
3808                    f * 1000 + i as u16,
3809                    "frame {} elem {}",
3810                    f,
3811                    i
3812                );
3813            }
3814        }
3815
3816        std::fs::remove_file(&path).ok();
3817    }
3818
3819    #[test]
3820    fn test_sub_frame_chunking_with_compression() {
3821        // Sub-frame chunk tiles must round-trip through a filter pipeline:
3822        // each write_chunk_at tile is compressed independently.
3823        let path = temp_path("hdf5_subchunk_zlib");
3824        let mut writer = Hdf5Writer::new();
3825        writer.set_chunk_size_auto(false);
3826        writer.set_n_row_chunks(4);
3827        writer.set_n_col_chunks(4);
3828        writer.set_compression_type(COMPRESS_ZLIB);
3829
3830        let mut arr = NDArray::new(
3831            vec![NDDimension::new(8), NDDimension::new(8)],
3832            NDDataType::UInt16,
3833        );
3834        for f in 0..2u16 {
3835            if let NDDataBuffer::U16(ref mut v) = arr.data {
3836                for (i, x) in v.iter_mut().enumerate() {
3837                    *x = f * 100 + i as u16;
3838                }
3839            }
3840            if f == 0 {
3841                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3842            }
3843            writer.write_file(&arr).unwrap();
3844        }
3845        writer.close_file().unwrap();
3846
3847        let h5 = H5File::open(&path).unwrap();
3848        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3849        assert_eq!(ds.shape(), vec![2, 8, 8]);
3850        assert_eq!(ds.chunk_dims(), Some(vec![1, 4, 4]));
3851        let raw: Vec<u16> = ds.read_raw().unwrap();
3852        for f in 0..2u16 {
3853            for i in 0..64usize {
3854                assert_eq!(raw[f as usize * 64 + i], f * 100 + i as u16);
3855            }
3856        }
3857
3858        std::fs::remove_file(&path).ok();
3859    }
3860
3861    #[test]
3862    fn test_non_dividing_chunk_is_honored_and_extent_trimmed() {
3863        // A chunk size that does not divide the frame is honored as-is;
3864        // write_chunk_at rounds the extent up, and close_file's set_extent
3865        // trims the dataset shape back to the exact [N, Y, X].
3866        let path = temp_path("hdf5_subchunk_nd");
3867        let mut writer = Hdf5Writer::new();
3868        writer.set_chunk_size_auto(false); // honor explicit chunk sizes
3869        writer.set_n_row_chunks(3); // Y = 8, 8 % 3 != 0 → honored
3870        writer.set_n_col_chunks(4); // X = 8 → honored
3871
3872        let mut arr = NDArray::new(
3873            vec![NDDimension::new(8), NDDimension::new(8)],
3874            NDDataType::UInt16,
3875        );
3876        if let NDDataBuffer::U16(ref mut v) = arr.data {
3877            for (i, x) in v.iter_mut().enumerate() {
3878                *x = i as u16;
3879            }
3880        }
3881        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3882        writer.write_file(&arr).unwrap();
3883        writer.write_file(&arr).unwrap();
3884        writer.close_file().unwrap();
3885
3886        let h5 = H5File::open(&path).unwrap();
3887        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3888        assert_eq!(ds.shape(), vec![2, 8, 8], "extent trimmed, not padded");
3889        assert_eq!(ds.chunk_dims(), Some(vec![1, 3, 4]));
3890        let raw: Vec<u16> = ds.read_raw().unwrap();
3891        assert_eq!(raw.len(), 2 * 64);
3892        for i in 0..64usize {
3893            assert_eq!(raw[i], i as u16);
3894            assert_eq!(raw[64 + i], i as u16);
3895        }
3896
3897        std::fs::remove_file(&path).ok();
3898    }
3899
3900    #[test]
3901    fn test_n_frames_chunks_band() {
3902        // HDF5_nFramesChunks groups frames into a multi-frame chunk band; the
3903        // logical frame count stays exact even when the last band is partial.
3904        let path = temp_path("hdf5_framechunks");
3905        let mut writer = Hdf5Writer::new();
3906        writer.set_chunk_size_auto(false);
3907        writer.set_n_frames_chunks(2); // 2 frames per chunk band
3908
3909        let mut arr = NDArray::new(
3910            vec![NDDimension::new(4), NDDimension::new(4)],
3911            NDDataType::UInt16,
3912        );
3913        // 5 frames → bands [0,1], [2,3], [4] (partial).
3914        for f in 0..5u16 {
3915            if let NDDataBuffer::U16(ref mut v) = arr.data {
3916                for (i, x) in v.iter_mut().enumerate() {
3917                    *x = f * 1000 + i as u16;
3918                }
3919            }
3920            if f == 0 {
3921                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3922            }
3923            writer.write_file(&arr).unwrap();
3924        }
3925        writer.close_file().unwrap();
3926
3927        let h5 = H5File::open(&path).unwrap();
3928        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3929        assert_eq!(ds.shape(), vec![5, 4, 4], "exact frame count, no padding");
3930        assert_eq!(ds.chunk_dims(), Some(vec![2, 4, 4]));
3931        let raw: Vec<u16> = ds.read_raw().unwrap();
3932        for f in 0..5u16 {
3933            for i in 0..16usize {
3934                assert_eq!(raw[f as usize * 16 + i], f * 1000 + i as u16);
3935            }
3936        }
3937
3938        std::fs::remove_file(&path).ok();
3939    }
3940
3941    #[test]
3942    fn test_frames_chunks_with_sub_frame_tiles() {
3943        // Full chunk geometry: nFramesChunks AND sub-frame row/col tiling at
3944        // once — exercises the complete flush_band [fc, rc, cc] tile grid
3945        // with a partial final band.
3946        let path = temp_path("hdf5_full_chunk");
3947        let mut writer = Hdf5Writer::new();
3948        writer.set_chunk_size_auto(false);
3949        writer.set_n_frames_chunks(2); // 2 frames per band
3950        writer.set_n_row_chunks(4); // Y = 8 → 2 row tiles
3951        writer.set_n_col_chunks(4); // X = 8 → 2 col tiles
3952
3953        let mut arr = NDArray::new(
3954            vec![NDDimension::new(8), NDDimension::new(8)],
3955            NDDataType::UInt16,
3956        );
3957        // 3 frames → band [0,1] full, band [2] partial; 2x2 tiles each.
3958        for f in 0..3u16 {
3959            if let NDDataBuffer::U16(ref mut v) = arr.data {
3960                for (i, x) in v.iter_mut().enumerate() {
3961                    *x = f * 1000 + i as u16;
3962                }
3963            }
3964            if f == 0 {
3965                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3966            }
3967            writer.write_file(&arr).unwrap();
3968        }
3969        writer.close_file().unwrap();
3970
3971        let h5 = H5File::open(&path).unwrap();
3972        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
3973        assert_eq!(ds.shape(), vec![3, 8, 8], "exact frame count");
3974        assert_eq!(ds.chunk_dims(), Some(vec![2, 4, 4]));
3975        let raw: Vec<u16> = ds.read_raw().unwrap();
3976        assert_eq!(raw.len(), 3 * 64);
3977        for f in 0..3u16 {
3978            for i in 0..64usize {
3979                assert_eq!(
3980                    raw[f as usize * 64 + i],
3981                    f * 1000 + i as u16,
3982                    "frame {} elem {}",
3983                    f,
3984                    i
3985                );
3986            }
3987        }
3988
3989        std::fs::remove_file(&path).ok();
3990    }
3991
3992    #[test]
3993    fn test_attribute_datasets() {
3994        let path = temp_path("hdf5_attr_ds");
3995        let mut writer = Hdf5Writer::new();
3996
3997        let mk = |exposure: f64, count: i32| {
3998            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
3999            arr.attributes.add(NDAttribute::new_static(
4000                "exposure",
4001                "",
4002                NDAttrSource::Driver,
4003                NDAttrValue::Float64(exposure),
4004            ));
4005            arr.attributes.add(NDAttribute::new_static(
4006                "count",
4007                "",
4008                NDAttrSource::Driver,
4009                NDAttrValue::Int32(count),
4010            ));
4011            arr
4012        };
4013
4014        let a0 = mk(0.5, 10);
4015        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4016        writer.write_file(&a0).unwrap();
4017        writer.write_file(&mk(0.75, 20)).unwrap();
4018        writer.write_file(&mk(1.25, 30)).unwrap();
4019        writer.close_file().unwrap();
4020
4021        let h5 = H5File::open(&path).unwrap();
4022        // One HDF5 dataset per NDAttribute, under NDAttributes/, [nframes].
4023        let exp = h5
4024            .dataset("entry/instrument/NDAttributes/exposure")
4025            .unwrap();
4026        assert_eq!(exp.shape(), vec![3]);
4027        let exp_vals: Vec<f64> = exp.read_raw().unwrap();
4028        assert_eq!(exp_vals, vec![0.5, 0.75, 1.25]);
4029
4030        let cnt = h5.dataset("entry/instrument/NDAttributes/count").unwrap();
4031        assert_eq!(cnt.shape(), vec![3]);
4032        // Numeric type preserved: i32, not stringified.
4033        let cnt_vals: Vec<i32> = cnt.read_raw().unwrap();
4034        assert_eq!(cnt_vals, vec![10, 20, 30]);
4035
4036        std::fs::remove_file(&path).ok();
4037    }
4038
4039    #[test]
4040    fn test_attribute_dataset_chunk_matches_capture_target() {
4041        // C `calculateAttributeChunking` (NDFileHDF5.cpp:2869-2920): with the
4042        // default auto chunk param (0) in a non-Single mode, the attribute
4043        // dataset chunks at NDFileNumCapture, NOT the actual frame count — the
4044        // dataset is extensible so the chunk may exceed the current extent.
4045        // Capture target 10, only 3 frames written: chunk dim 0 must be 10,
4046        // extent 3; values still round-trip through the single padded chunk.
4047        let path = temp_path("hdf5_attr_chunk_cap");
4048        let mut writer = Hdf5Writer::new();
4049        writer.set_num_capture(10);
4050
4051        let mk = |c: i32| {
4052            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4053            arr.attributes.add(NDAttribute::new_static(
4054                "count",
4055                "",
4056                NDAttrSource::Driver,
4057                NDAttrValue::Int32(c),
4058            ));
4059            arr
4060        };
4061
4062        let a0 = mk(1);
4063        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4064        writer.write_file(&a0).unwrap();
4065        writer.write_file(&mk(2)).unwrap();
4066        writer.write_file(&mk(3)).unwrap();
4067        writer.close_file().unwrap();
4068
4069        let h5 = H5File::open(&path).unwrap();
4070        let ds = h5.dataset("entry/instrument/NDAttributes/count").unwrap();
4071        assert_eq!(ds.shape(), vec![3]);
4072        assert_eq!(ds.chunk_dims(), Some(vec![10]));
4073        let vals: Vec<i32> = ds.read_raw().unwrap();
4074        assert_eq!(vals, vec![1, 2, 3]);
4075        std::fs::remove_file(&path).ok();
4076    }
4077
4078    #[test]
4079    fn test_attribute_dataset_chunk_single_mode_is_one() {
4080        // Auto chunk param (0) in Single mode resolves to 1
4081        // (`calculateAttributeChunking`: fileWriteMode == NDFileModeSingle → 1),
4082        // regardless of the capture target.
4083        let path = temp_path("hdf5_attr_chunk_single");
4084        let mut writer = Hdf5Writer::new();
4085        writer.set_num_capture(64);
4086
4087        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4088        arr.attributes.add(NDAttribute::new_static(
4089            "count",
4090            "",
4091            NDAttrSource::Driver,
4092            NDAttrValue::Int32(7),
4093        ));
4094        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4095        writer.write_file(&arr).unwrap();
4096        writer.close_file().unwrap();
4097
4098        let h5 = H5File::open(&path).unwrap();
4099        let ds = h5.dataset("entry/instrument/NDAttributes/count").unwrap();
4100        assert_eq!(ds.chunk_dims(), Some(vec![1]));
4101        std::fs::remove_file(&path).ok();
4102    }
4103
4104    #[test]
4105    fn test_string_attribute_dataset_is_rank1_fixed_string() {
4106        // C stores a string-valued NDAttribute as a rank-1 `[n]` dataset of a
4107        // fixed 256-byte H5T_C_S1 string (NDFileHDF5AttributeDataset.cpp:321-323,
4108        // rank_=1), NOT a 2-D `[n,256]` uint8 array. Verify rank 1, a 256-byte
4109        // string element, and that the null-terminated bytes round-trip.
4110        let path = temp_path("hdf5_attr_str");
4111        let mut writer = Hdf5Writer::new();
4112
4113        let mk = |mode_name: &str| {
4114            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4115            arr.attributes.add(NDAttribute::new_static(
4116                "ColorMode",
4117                "",
4118                NDAttrSource::Driver,
4119                NDAttrValue::String(mode_name.to_string()),
4120            ));
4121            arr
4122        };
4123
4124        let a0 = mk("Mono");
4125        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4126        writer.write_file(&a0).unwrap();
4127        writer.write_file(&mk("RGB1")).unwrap();
4128        writer.close_file().unwrap();
4129
4130        let h5 = H5File::open(&path).unwrap();
4131        // The default layout declares ColorMode as a `<dataset source="ndattribute">`
4132        // in the detector subgroup, so it is routed there, not the default group.
4133        let ds = h5
4134            .dataset("entry/instrument/detector/NDAttributes/ColorMode")
4135            .unwrap();
4136        // Rank-1 [2] of a 256-byte fixed-length string element, not [2,256] u8.
4137        assert_eq!(ds.shape(), vec![2]);
4138        assert_eq!(ds.element_size(), MAX_ATTRIBUTE_STRING_SIZE);
4139
4140        let frames: Vec<FixedStr256> = ds.read_raw().unwrap();
4141        assert_eq!(frames.len(), 2);
4142        let decode = |f: &FixedStr256| -> String {
4143            f.0.iter()
4144                .take_while(|&&b| b != 0)
4145                .map(|&b| b as char)
4146                .collect()
4147        };
4148        assert_eq!(decode(&frames[0]), "Mono");
4149        assert_eq!(decode(&frames[1]), "RGB1");
4150        std::fs::remove_file(&path).ok();
4151    }
4152
4153    #[test]
4154    fn test_ndattribute_dataset_routed_to_declared_group() {
4155        // C `find_dset_ndattr` (NDFileHDF5.cpp:2792) places an NDAttribute that
4156        // matches a `<dataset source="ndattribute">` declaration into that
4157        // dataset's parent group; the default layout declares ColorMode in the
4158        // detector subgroup. An NDAttribute with no declaration falls back to
4159        // the `ndattr_default` group. Verify both in a single file.
4160        let path = temp_path("hdf5_attr_route");
4161        let mut writer = Hdf5Writer::new();
4162
4163        let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4164        arr.attributes.add(NDAttribute::new_static(
4165            "ColorMode",
4166            "",
4167            NDAttrSource::Driver,
4168            NDAttrValue::String("Mono".to_string()),
4169        ));
4170        arr.attributes.add(NDAttribute::new_static(
4171            "count",
4172            "",
4173            NDAttrSource::Driver,
4174            NDAttrValue::Int32(7),
4175        ));
4176
4177        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
4178        writer.write_file(&arr).unwrap();
4179        writer.close_file().unwrap();
4180
4181        let h5 = H5File::open(&path).unwrap();
4182        // Declared → routed into the detector subgroup.
4183        assert!(
4184            h5.dataset("entry/instrument/detector/NDAttributes/ColorMode")
4185                .is_ok()
4186        );
4187        // Undeclared → default ndattr group.
4188        assert!(h5.dataset("entry/instrument/NDAttributes/count").is_ok());
4189        // ColorMode must NOT also appear in the default group.
4190        assert!(
4191            h5.dataset("entry/instrument/NDAttributes/ColorMode")
4192                .is_err()
4193        );
4194        std::fs::remove_file(&path).ok();
4195    }
4196
4197    #[test]
4198    fn test_ndattribute_element_attr_open_close_values() {
4199        // C `storeOnOpenCloseAttribute` (NDFileHDF5.cpp:553-632) writes a
4200        // layout `<attribute source="ndattribute">` as an HDF5 attribute on its
4201        // group/dataset: `when="OnFileOpen"` (and the default `OnFrame`) take
4202        // the first-frame value, `when="OnFileClose"` the last. Cover a group
4203        // string attribute and a dataset numeric attribute, both phases.
4204        let dir = std::env::temp_dir();
4205        let layout = dir.join("adcore_layout_elem_attr.xml");
4206        std::fs::write(
4207            &layout,
4208            r#"<hdf5_layout>
4209              <group name="entry">
4210                <group name="instrument">
4211                  <group name="detector">
4212                    <attribute name="FirstColorMode" source="ndattribute" ndattribute="ColorMode" when="OnFileOpen"/>
4213                    <attribute name="LastColorMode" source="ndattribute" ndattribute="ColorMode" when="OnFileClose"/>
4214                    <attribute name="DefaultColorMode" source="ndattribute" ndattribute="ColorMode"/>
4215                    <dataset name="data" source="detector" det_default="true">
4216                      <attribute name="GainAtOpen" source="ndattribute" ndattribute="Gain" when="OnFileOpen"/>
4217                      <attribute name="GainAtClose" source="ndattribute" ndattribute="Gain" when="OnFileClose"/>
4218                    </dataset>
4219                  </group>
4220                  <group name="NDAttributes" ndattr_default="true"/>
4221                </group>
4222              </group>
4223            </hdf5_layout>"#,
4224        )
4225        .unwrap();
4226
4227        let path = temp_path("hdf5_elem_attr");
4228        let mut writer = Hdf5Writer::new();
4229        assert!(
4230            writer.set_layout_filename(layout.to_str().unwrap()),
4231            "layout XML must parse: {}",
4232            writer.layout_error
4233        );
4234
4235        let mk = |mode: &str, gain: i32| {
4236            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4237            arr.attributes.add(NDAttribute::new_static(
4238                "ColorMode",
4239                "",
4240                NDAttrSource::Driver,
4241                NDAttrValue::String(mode.to_string()),
4242            ));
4243            arr.attributes.add(NDAttribute::new_static(
4244                "Gain",
4245                "",
4246                NDAttrSource::Driver,
4247                NDAttrValue::Int32(gain),
4248            ));
4249            arr
4250        };
4251
4252        let a0 = mk("Mono", 10);
4253        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4254        writer.write_file(&a0).unwrap();
4255        writer.write_file(&mk("RGB1", 20)).unwrap();
4256        writer.write_file(&mk("Bayer", 30)).unwrap();
4257        writer.close_file().unwrap();
4258
4259        let h5 = H5File::open(&path).unwrap();
4260
4261        // Group string element-attributes on /entry/instrument/detector.
4262        let mut grp = h5.root_group();
4263        for seg in ["entry", "instrument", "detector"] {
4264            grp = grp.group(seg).unwrap();
4265        }
4266        // OnFileOpen and the default (OnFrame, open wins) take the first value.
4267        assert_eq!(grp.attr_string("FirstColorMode").unwrap(), "Mono");
4268        assert_eq!(grp.attr_string("DefaultColorMode").unwrap(), "Mono");
4269        // OnFileClose takes the last value.
4270        assert_eq!(grp.attr_string("LastColorMode").unwrap(), "Bayer");
4271
4272        // Dataset numeric element-attributes on the detector dataset.
4273        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4274        assert_eq!(
4275            ds.attr("GainAtOpen")
4276                .unwrap()
4277                .read_numeric::<i32>()
4278                .unwrap(),
4279            10
4280        );
4281        assert_eq!(
4282            ds.attr("GainAtClose")
4283                .unwrap()
4284                .read_numeric::<i32>()
4285                .unwrap(),
4286            30
4287        );
4288
4289        std::fs::remove_file(&path).ok();
4290        std::fs::remove_file(&layout).ok();
4291    }
4292
4293    #[test]
4294    fn test_ndattr_element_attr_on_lazily_created_dataset() {
4295        // C storeOnOpenCloseAttribute attaches a layout `<attribute
4296        // source="ndattribute">` to ANY element the layout names, not just the
4297        // detector dataset. A lazily-created NDAttribute dataset has no retained
4298        // create-time handle, so its element-attrs are attached by reopening the
4299        // dataset by name in write mode (rust-hdf5 0.2.22 dataset_writer). Here
4300        // a `GainTrace` NDAttribute dataset (sourced from Gain) carries two
4301        // element-attrs sourced from ColorMode (open=first, close=last value).
4302        let dir = std::env::temp_dir();
4303        let layout = dir.join("adcore_layout_lazy_ds_attr.xml");
4304        std::fs::write(
4305            &layout,
4306            r#"<hdf5_layout>
4307              <group name="entry">
4308                <group name="instrument">
4309                  <group name="detector">
4310                    <dataset name="data" source="detector" det_default="true"/>
4311                    <dataset name="GainTrace" source="ndattribute" ndattribute="Gain">
4312                      <attribute name="ModeAtOpen" source="ndattribute" ndattribute="ColorMode" when="OnFileOpen"/>
4313                      <attribute name="ModeAtClose" source="ndattribute" ndattribute="ColorMode" when="OnFileClose"/>
4314                    </dataset>
4315                  </group>
4316                  <group name="NDAttributes" ndattr_default="true"/>
4317                </group>
4318              </group>
4319            </hdf5_layout>"#,
4320        )
4321        .unwrap();
4322
4323        let path = temp_path("hdf5_lazy_ds_attr");
4324        let mut writer = Hdf5Writer::new();
4325        assert!(
4326            writer.set_layout_filename(layout.to_str().unwrap()),
4327            "layout XML must parse: {}",
4328            writer.layout_error
4329        );
4330
4331        let mk = |mode: &str, gain: i32| {
4332            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4333            arr.attributes.add(NDAttribute::new_static(
4334                "ColorMode",
4335                "",
4336                NDAttrSource::Driver,
4337                NDAttrValue::String(mode.to_string()),
4338            ));
4339            arr.attributes.add(NDAttribute::new_static(
4340                "Gain",
4341                "",
4342                NDAttrSource::Driver,
4343                NDAttrValue::Int32(gain),
4344            ));
4345            arr
4346        };
4347
4348        let a0 = mk("Mono", 10);
4349        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4350        writer.write_file(&a0).unwrap();
4351        writer.write_file(&mk("RGB1", 20)).unwrap();
4352        writer.write_file(&mk("Bayer", 30)).unwrap();
4353        writer.close_file().unwrap();
4354
4355        let h5 = H5File::open(&path).unwrap();
4356        // The element-attrs are attached to the lazily-created NDAttribute
4357        // dataset (not a detector dataset, so reopened by name at close).
4358        let ds = h5.dataset("entry/instrument/detector/GainTrace").unwrap();
4359        assert_eq!(
4360            ds.attr("ModeAtOpen").unwrap().read_string().unwrap(),
4361            "Mono"
4362        );
4363        assert_eq!(
4364            ds.attr("ModeAtClose").unwrap().read_string().unwrap(),
4365            "Bayer"
4366        );
4367        std::fs::remove_file(&path).ok();
4368        std::fs::remove_file(&layout).ok();
4369    }
4370
4371    #[test]
4372    fn test_swmr_ndattr_element_attr_on_streaming_dataset() {
4373        // In SWMR mode an open-time `<attribute source="ndattribute">` on the
4374        // streaming dataset is attached before the SWMR lock via
4375        // set_dataset_attr_* (rust-hdf5 0.2.22 addresses SWMR dataset attributes
4376        // by index). OnFileClose stays impossible in SWMR (HDF5 forbids
4377        // post-lock attribute creation; C's close-time H5Acreate2 fails too).
4378        let dir = std::env::temp_dir();
4379        let layout = dir.join("adcore_layout_swmr_ds_attr.xml");
4380        std::fs::write(
4381            &layout,
4382            r#"<hdf5_layout>
4383              <group name="entry">
4384                <group name="instrument">
4385                  <group name="detector">
4386                    <dataset name="data" source="detector" det_default="true">
4387                      <attribute name="ModeAtOpen" source="ndattribute" ndattribute="ColorMode" when="OnFileOpen"/>
4388                    </dataset>
4389                  </group>
4390                  <group name="NDAttributes" ndattr_default="true"/>
4391                </group>
4392              </group>
4393            </hdf5_layout>"#,
4394        )
4395        .unwrap();
4396
4397        let path = temp_path("hdf5_swmr_ds_attr");
4398        let mut writer = Hdf5Writer::new();
4399        writer.set_swmr_mode(true);
4400        assert!(
4401            writer.set_layout_filename(layout.to_str().unwrap()),
4402            "layout XML must parse: {}",
4403            writer.layout_error
4404        );
4405
4406        let mk = |mode: &str| {
4407            let mut arr = NDArray::new(
4408                vec![NDDimension::new(4), NDDimension::new(4)],
4409                NDDataType::UInt16,
4410            );
4411            arr.attributes.add(NDAttribute::new_static(
4412                "ColorMode",
4413                "",
4414                NDAttrSource::Driver,
4415                NDAttrValue::String(mode.to_string()),
4416            ));
4417            arr
4418        };
4419
4420        let a0 = mk("Mono");
4421        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4422        writer.write_file(&a0).unwrap();
4423        writer.write_file(&mk("RGB1")).unwrap();
4424        writer.close_file().unwrap();
4425
4426        let h5 = H5File::open(&path).unwrap();
4427        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4428        assert_eq!(
4429            ds.attr("ModeAtOpen").unwrap().read_string().unwrap(),
4430            "Mono"
4431        );
4432        std::fs::remove_file(&path).ok();
4433        std::fs::remove_file(&layout).ok();
4434    }
4435
4436    #[test]
4437    fn test_swmr_ndarray_default_attrs_on_streaming_dataset() {
4438        // C writeDefaultDatasetAttributes (NDFileHDF5.cpp:3695-3719) attaches
4439        // NDArrayNumDims plus the per-dimension NDArrayDimOffset/Binning/Reverse
4440        // to every detector dataset. In SWMR mode they are written on the single
4441        // streaming dataset before start_swmr() locks the file, addressed by
4442        // dataset index (rust-hdf5 0.2.22). writeH5attrInt32
4443        // (NDFileHDF5.cpp:1142-1191): 1-D array => scalar int32, multi-dim => a
4444        // 1-D int32 array of length ndims, native dim order.
4445
4446        // 1-D streaming array: all four attributes present and scalar.
4447        let path = temp_path("hdf5_swmr_dimattr_1d");
4448        let mut writer = Hdf5Writer::new();
4449        writer.set_swmr_mode(true);
4450        let mut d = NDDimension::new(8);
4451        d.offset = 3;
4452        d.binning = 2;
4453        d.reverse = true;
4454        let arr = NDArray::new(vec![d], NDDataType::UInt16);
4455        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
4456        writer.write_file(&arr).unwrap();
4457        writer.write_file(&arr).unwrap();
4458        writer.close_file().unwrap();
4459        let h5 = H5File::open(&path).unwrap();
4460        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4461        let geti = |n: &str| -> i32 { ds.attr(n).unwrap().read_numeric().unwrap() };
4462        assert_eq!(geti("NDArrayNumDims"), 1);
4463        assert_eq!(geti("NDArrayDimOffset"), 3);
4464        assert_eq!(geti("NDArrayDimBinning"), 2);
4465        assert_eq!(geti("NDArrayDimReverse"), 1);
4466        std::fs::remove_file(&path).ok();
4467
4468        // 2-D streaming array: NDArrayNumDims=2; the Dim* attributes are 1-D
4469        // int32 arrays of length 2 in native dim order (dims[0] then dims[1]).
4470        let path = temp_path("hdf5_swmr_dimattr_2d");
4471        let mut writer = Hdf5Writer::new();
4472        writer.set_swmr_mode(true);
4473        let mut d0 = NDDimension::new(4);
4474        d0.offset = 3;
4475        d0.binning = 2;
4476        d0.reverse = true;
4477        let mut d1 = NDDimension::new(8);
4478        d1.offset = 5;
4479        d1.binning = 4;
4480        d1.reverse = false;
4481        let arr = NDArray::new(vec![d0, d1], NDDataType::UInt16);
4482        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
4483        writer.write_file(&arr).unwrap();
4484        writer.write_file(&arr).unwrap();
4485        writer.close_file().unwrap();
4486        let h5 = H5File::open(&path).unwrap();
4487        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4488        let numdims: i32 = ds.attr("NDArrayNumDims").unwrap().read_numeric().unwrap();
4489        assert_eq!(numdims, 2);
4490        let read_i32_arr = |n: &str| -> Vec<i32> {
4491            let raw = ds.attr(n).unwrap().read_raw().unwrap();
4492            assert_eq!(raw.len(), 2 * 4, "{n} must be a 2-element int32 array");
4493            raw.chunks_exact(4)
4494                .map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
4495                .collect()
4496        };
4497        assert_eq!(read_i32_arr("NDArrayDimOffset"), vec![3, 5]);
4498        assert_eq!(read_i32_arr("NDArrayDimBinning"), vec![2, 4]);
4499        assert_eq!(read_i32_arr("NDArrayDimReverse"), vec![1, 0]);
4500        std::fs::remove_file(&path).ok();
4501    }
4502
4503    #[test]
4504    fn test_ndattr_descriptor_attributes_written() {
4505        // C attaches NDAttrName/NDAttrDescription/NDAttrSourceType/NDAttrSource
4506        // string HDF5 attributes (non-empty only) to every NDAttribute dataset
4507        // (NDFileHDF5.cpp:2715, 2817-2822).
4508        let path = temp_path("hdf5_attr_desc");
4509        let mut writer = Hdf5Writer::new();
4510
4511        let mk = |v: f64| {
4512            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
4513            arr.attributes.add(NDAttribute::new_static(
4514                "AcquireTime",
4515                "exposure time",
4516                NDAttrSource::EpicsPV("13SIM1:cam1:AcquireTime_RBV".to_string()),
4517                NDAttrValue::Float64(v),
4518            ));
4519            arr
4520        };
4521
4522        let a0 = mk(0.1);
4523        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
4524        writer.write_file(&a0).unwrap();
4525        writer.write_file(&mk(0.2)).unwrap();
4526        writer.close_file().unwrap();
4527
4528        let h5 = H5File::open(&path).unwrap();
4529        let ds = h5
4530            .dataset("entry/instrument/NDAttributes/AcquireTime")
4531            .unwrap();
4532        let read = |n: &str| ds.attr(n).unwrap().read_string().unwrap();
4533        assert_eq!(read("NDAttrName"), "AcquireTime");
4534        assert_eq!(read("NDAttrDescription"), "exposure time");
4535        assert_eq!(read("NDAttrSourceType"), "NDAttrSourceEPICSPV");
4536        assert_eq!(read("NDAttrSource"), "13SIM1:cam1:AcquireTime_RBV");
4537        std::fs::remove_file(&path).ok();
4538    }
4539
4540    #[test]
4541    fn test_detector_dataset_ndarray_dim_attributes() {
4542        // C writeDefaultDatasetAttributes (NDFileHDF5.cpp:3695-3719) attaches
4543        // NDArrayNumDims (scalar) and the per-dimension NDArrayDimOffset/Binning/
4544        // Reverse. writeH5attrInt32 (NDFileHDF5.cpp:1142-1191) emits a single
4545        // dimension as a scalar int32 and multiple dimensions as a 1-D int32
4546        // array of length ndims, in native dim order.
4547
4548        // 1-D array: all four attributes present and scalar, in native order.
4549        let path = temp_path("hdf5_dimattr_1d");
4550        let mut writer = Hdf5Writer::new();
4551        let mut d = NDDimension::new(8);
4552        d.offset = 3;
4553        d.binning = 2;
4554        d.reverse = true;
4555        let arr = NDArray::new(vec![d], NDDataType::UInt16);
4556        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4557        writer.write_file(&arr).unwrap();
4558        writer.close_file().unwrap();
4559        let h5 = H5File::open(&path).unwrap();
4560        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4561        let geti = |n: &str| -> i32 { ds.attr(n).unwrap().read_numeric().unwrap() };
4562        assert_eq!(geti("NDArrayNumDims"), 1);
4563        assert_eq!(geti("NDArrayDimOffset"), 3);
4564        assert_eq!(geti("NDArrayDimBinning"), 2);
4565        assert_eq!(geti("NDArrayDimReverse"), 1);
4566        std::fs::remove_file(&path).ok();
4567
4568        // 2-D array: NDArrayNumDims present (=2); the Dim* attributes are 1-D
4569        // int32 arrays of length 2, in native dim order. Distinct per-dim values
4570        // verify the order is dims[0] then dims[1] (not reversed HDF5 axes).
4571        let path = temp_path("hdf5_dimattr_2d");
4572        let mut writer = Hdf5Writer::new();
4573        let mut d0 = NDDimension::new(4);
4574        d0.offset = 3;
4575        d0.binning = 2;
4576        d0.reverse = true;
4577        let mut d1 = NDDimension::new(8);
4578        d1.offset = 5;
4579        d1.binning = 4;
4580        d1.reverse = false;
4581        let arr = NDArray::new(vec![d0, d1], NDDataType::UInt16);
4582        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4583        writer.write_file(&arr).unwrap();
4584        writer.close_file().unwrap();
4585        let h5 = H5File::open(&path).unwrap();
4586        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4587        let numdims: i32 = ds.attr("NDArrayNumDims").unwrap().read_numeric().unwrap();
4588        assert_eq!(numdims, 2);
4589        // The array attribute is a 1-D simple dataspace of length ndims: read the
4590        // raw int32 LE bytes back (4 bytes/elem => 8 bytes proves an array, not a
4591        // scalar). h5py/libhdf5 reads the same shape and values.
4592        let read_i32_arr = |n: &str| -> Vec<i32> {
4593            let raw = ds.attr(n).unwrap().read_raw().unwrap();
4594            assert_eq!(raw.len(), 2 * 4, "{n} must be a 2-element int32 array");
4595            raw.chunks_exact(4)
4596                .map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
4597                .collect()
4598        };
4599        assert_eq!(read_i32_arr("NDArrayDimOffset"), vec![3, 5]);
4600        assert_eq!(read_i32_arr("NDArrayDimBinning"), vec![2, 4]);
4601        assert_eq!(read_i32_arr("NDArrayDimReverse"), vec![1, 0]);
4602        std::fs::remove_file(&path).ok();
4603    }
4604
4605    #[test]
4606    fn test_fill_value_recorded_on_dataset() {
4607        // The configured HDF5_fillValue reaches the DCPL via rust-hdf5 0.2.15's
4608        // `DatasetBuilder::fill_value`; it is also mirrored as a dataset
4609        // attribute for tooling. Verify both the attribute and that an
4610        // unwritten region of a fill-valued dataset reads back as `fill`.
4611        let path = temp_path("hdf5_fill");
4612        let mut writer = Hdf5Writer::new();
4613        writer.set_fill_value(7.5);
4614
4615        let arr = NDArray::new(
4616            vec![NDDimension::new(4), NDDimension::new(4)],
4617            NDDataType::UInt16,
4618        );
4619        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4620        writer.write_file(&arr).unwrap();
4621        writer.close_file().unwrap();
4622
4623        let h5 = H5File::open(&path).unwrap();
4624        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
4625        let fv: f64 = ds.attr("HDF5_fillValue").unwrap().read_numeric().unwrap();
4626        assert_eq!(fv, 7.5);
4627        std::fs::remove_file(&path).ok();
4628
4629        // Direct DCPL check: a fixed-shape dataset created with fill_value and
4630        // never written reads back the fill value, not zero.
4631        let path2 = temp_path("hdf5_fill_dcpl");
4632        {
4633            let f = H5File::create(&path2).unwrap();
4634            let _ = f
4635                .new_dataset::<i32>()
4636                .shape(&[8][..])
4637                .fill_value(42i32)
4638                .create("unwritten")
4639                .unwrap();
4640        }
4641        let h5b = H5File::open(&path2).unwrap();
4642        let vals: Vec<i32> = h5b.dataset("unwritten").unwrap().read_raw().unwrap();
4643        assert_eq!(vals, vec![42i32; 8]);
4644        std::fs::remove_file(&path2).ok();
4645    }
4646
4647    #[test]
4648    fn test_performance_dataset() {
4649        let path = temp_path("hdf5_perf");
4650        let mut writer = Hdf5Writer::new();
4651        writer.set_store_performance(true);
4652
4653        let arr = NDArray::new(
4654            vec![NDDimension::new(8), NDDimension::new(8)],
4655            NDDataType::UInt16,
4656        );
4657        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
4658        writer.write_file(&arr).unwrap();
4659        writer.write_file(&arr).unwrap();
4660        writer.close_file().unwrap();
4661
4662        let h5 = H5File::open(&path).unwrap();
4663        let ts = h5
4664            .dataset("entry/instrument/performance/timestamp")
4665            .unwrap();
4666        assert_eq!(ts.shape(), vec![2, 5]);
4667        let vals: Vec<f64> = ts.read_raw().unwrap();
4668        assert_eq!(vals.len(), 10);
4669
4670        std::fs::remove_file(&path).ok();
4671    }
4672
4673    #[test]
4674    fn test_performance_dataset_chunk_matches_capture_target() {
4675        // C `writePerformanceDataset` chunks the timestamp dataset `[chunking,5]`
4676        // (NDFileHDF5.cpp:2645-2647) where `chunking` is the same
4677        // `calculateAttributeChunking` value (the capture target in non-Single
4678        // mode), not one row per chunk. Capture target 8, 2 frames written: the
4679        // chunk's leading dim must be 8, extent 2; doubles still round-trip.
4680        let path = temp_path("hdf5_perf_chunk");
4681        let mut writer = Hdf5Writer::new();
4682        writer.set_store_performance(true);
4683        writer.set_num_capture(8);
4684
4685        let arr = NDArray::new(
4686            vec![NDDimension::new(8), NDDimension::new(8)],
4687            NDDataType::UInt16,
4688        );
4689        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
4690        writer.write_file(&arr).unwrap();
4691        writer.write_file(&arr).unwrap();
4692        writer.close_file().unwrap();
4693
4694        let h5 = H5File::open(&path).unwrap();
4695        let ts = h5
4696            .dataset("entry/instrument/performance/timestamp")
4697            .unwrap();
4698        assert_eq!(ts.shape(), vec![2, 5]);
4699        assert_eq!(ts.chunk_dims(), Some(vec![8, 5]));
4700        let vals: Vec<f64> = ts.read_raw().unwrap();
4701        assert_eq!(vals.len(), 10);
4702
4703        std::fs::remove_file(&path).ok();
4704    }
4705
4706    #[test]
4707    fn test_roundtrip_all_types() {
4708        macro_rules! roundtrip {
4709            ($name:expr, $dt:expr, $variant:ident, $ty:ty, $vals:expr) => {{
4710                let path = temp_path($name);
4711                let mut writer = Hdf5Writer::new();
4712                let mut arr = NDArray::new(vec![NDDimension::new(4)], $dt);
4713                if let NDDataBuffer::$variant(ref mut v) = arr.data {
4714                    let src: Vec<$ty> = $vals;
4715                    v.copy_from_slice(&src);
4716                }
4717                writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4718                writer.write_file(&arr).unwrap();
4719                writer.close_file().unwrap();
4720
4721                let mut reader = Hdf5Writer::new();
4722                reader.current_path = Some(path.clone());
4723                let r = reader.read_file().unwrap();
4724                assert_eq!(r.data.data_type(), $dt, "type for {}", $name);
4725                if let NDDataBuffer::$variant(ref v) = r.data {
4726                    let src: Vec<$ty> = $vals;
4727                    assert_eq!(v, &src, "values for {}", $name);
4728                } else {
4729                    panic!("wrong buffer variant for {}", $name);
4730                }
4731                std::fs::remove_file(&path).ok();
4732            }};
4733        }
4734
4735        roundtrip!("rt_i8", NDDataType::Int8, I8, i8, vec![-1, 0, 1, 127]);
4736        roundtrip!("rt_u8", NDDataType::UInt8, U8, u8, vec![0, 1, 200, 255]);
4737        roundtrip!(
4738            "rt_i16",
4739            NDDataType::Int16,
4740            I16,
4741            i16,
4742            vec![-32768, -1, 1, 32767]
4743        );
4744        roundtrip!(
4745            "rt_u16",
4746            NDDataType::UInt16,
4747            U16,
4748            u16,
4749            vec![0, 1, 40000, 65535]
4750        );
4751        roundtrip!(
4752            "rt_i32",
4753            NDDataType::Int32,
4754            I32,
4755            i32,
4756            vec![i32::MIN, -1, 1, i32::MAX]
4757        );
4758        roundtrip!(
4759            "rt_u32",
4760            NDDataType::UInt32,
4761            U32,
4762            u32,
4763            vec![0, 1, 3_000_000_000, u32::MAX]
4764        );
4765        roundtrip!(
4766            "rt_i64",
4767            NDDataType::Int64,
4768            I64,
4769            i64,
4770            vec![i64::MIN, -1, 1, i64::MAX]
4771        );
4772        roundtrip!(
4773            "rt_u64",
4774            NDDataType::UInt64,
4775            U64,
4776            u64,
4777            vec![0, 1, 9_000_000_000, u64::MAX]
4778        );
4779        roundtrip!(
4780            "rt_f32",
4781            NDDataType::Float32,
4782            F32,
4783            f32,
4784            vec![-1.5, 0.0, 2.25, 3.75]
4785        );
4786        roundtrip!(
4787            "rt_f64",
4788            NDDataType::Float64,
4789            F64,
4790            f64,
4791            vec![-1.5, 0.0, 2.25, 3.75]
4792        );
4793    }
4794
4795    #[test]
4796    fn test_deflate_compressed_write() {
4797        let path = temp_path("hdf5_deflate");
4798        let mut writer = Hdf5Writer::new();
4799        writer.set_compression_type(COMPRESS_ZLIB);
4800        writer.set_z_compress_level(6);
4801
4802        let mut arr = NDArray::new(
4803            vec![NDDimension::new(64), NDDimension::new(64)],
4804            NDDataType::UInt16,
4805        );
4806        if let NDDataBuffer::U16(ref mut v) = arr.data {
4807            for i in 0..v.len() {
4808                v[i] = (i % 256) as u16;
4809            }
4810        }
4811
4812        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4813        writer.write_file(&arr).unwrap();
4814        writer.close_file().unwrap();
4815
4816        let file_size = std::fs::metadata(&path).unwrap().len();
4817        assert!(
4818            file_size < 8192,
4819            "compressed file should be smaller than raw data"
4820        );
4821
4822        let h5file = H5File::open(&path).unwrap();
4823        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
4824        let data: Vec<u16> = ds.read_raw().unwrap();
4825        assert_eq!(data.len(), 64 * 64);
4826        assert_eq!(data[0], 0);
4827        assert_eq!(data[255], 255);
4828        assert_eq!(data[256], 0);
4829
4830        std::fs::remove_file(&path).ok();
4831    }
4832
4833    #[test]
4834    fn test_lz4_compressed_write() {
4835        let path = temp_path("hdf5_lz4");
4836        let mut writer = Hdf5Writer::new();
4837        writer.set_compression_type(COMPRESS_LZ4);
4838
4839        let mut arr = NDArray::new(
4840            vec![NDDimension::new(32), NDDimension::new(32)],
4841            NDDataType::UInt8,
4842        );
4843        if let NDDataBuffer::U8(ref mut v) = arr.data {
4844            for i in 0..v.len() {
4845                v[i] = (i % 4) as u8;
4846            }
4847        }
4848
4849        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4850        writer.write_file(&arr).unwrap();
4851        writer.close_file().unwrap();
4852
4853        let h5file = H5File::open(&path).unwrap();
4854        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
4855        let data: Vec<u8> = ds.read_raw().unwrap();
4856        assert_eq!(data.len(), 32 * 32);
4857        assert_eq!(data[0], 0);
4858        assert_eq!(data[3], 3);
4859
4860        std::fs::remove_file(&path).ok();
4861    }
4862
4863    #[test]
4864    fn test_bitshuffle_compressed_write() {
4865        let path = temp_path("hdf5_bshuf");
4866        let mut writer = Hdf5Writer::new();
4867        writer.set_compression_type(COMPRESS_BSHUF);
4868
4869        let mut arr = NDArray::new(
4870            vec![NDDimension::new(64), NDDimension::new(64)],
4871            NDDataType::UInt16,
4872        );
4873        if let NDDataBuffer::U16(ref mut v) = arr.data {
4874            for i in 0..v.len() {
4875                v[i] = (i % 8) as u16;
4876            }
4877        }
4878
4879        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4880        writer.write_file(&arr).unwrap();
4881        writer.close_file().unwrap();
4882
4883        let h5file = H5File::open(&path).unwrap();
4884        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
4885        let data: Vec<u16> = ds.read_raw().unwrap();
4886        assert_eq!(data.len(), 64 * 64);
4887        assert_eq!(data[0], 0);
4888        assert_eq!(data[9], 1);
4889
4890        std::fs::remove_file(&path).ok();
4891    }
4892
4893    #[test]
4894    fn test_szip_uses_nearest_neighbor_mask() {
4895        // C uses H5_SZIP_NN_OPTION_MASK (32); the SZIP filter's cd_values[0]
4896        // must declare NN coding, and the data must round-trip.
4897        let mut writer = Hdf5Writer::new();
4898        writer.set_compression_type(COMPRESS_SZIP);
4899        let pipeline = writer.build_pipeline(1).expect("szip pipeline");
4900        assert_eq!(pipeline.filters[0].cd_values[0], SZIP_NN_OPTION_MASK);
4901
4902        let path = temp_path("hdf5_szip_nn");
4903        let mut arr = NDArray::new(
4904            vec![NDDimension::new(64), NDDimension::new(64)],
4905            NDDataType::UInt8,
4906        );
4907        if let NDDataBuffer::U8(ref mut v) = arr.data {
4908            for (i, e) in v.iter_mut().enumerate() {
4909                *e = (i % 13) as u8;
4910            }
4911        }
4912        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4913        writer.write_file(&arr).unwrap();
4914        writer.close_file().unwrap();
4915
4916        let h5file = H5File::open(&path).unwrap();
4917        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
4918        let data: Vec<u8> = ds.read_raw().unwrap();
4919        assert_eq!(data.len(), 64 * 64);
4920        assert_eq!(data[20], (20 % 13) as u8);
4921        std::fs::remove_file(&path).ok();
4922    }
4923
4924    #[test]
4925    fn test_blosc_default_shuffle_is_byte_shuffle() {
4926        // C default bloscShuffleType=1 (byte shuffle), NDFileHDF5.cpp:2344;
4927        // BLOSC cd_values[5] carries the shuffle type.
4928        let mut writer = Hdf5Writer::new();
4929        writer.set_compression_type(COMPRESS_BLOSC);
4930        let pipeline = writer.build_pipeline(2).expect("blosc pipeline");
4931        assert_eq!(pipeline.filters[0].cd_values[5], 1);
4932    }
4933
4934    #[test]
4935    fn test_nbit_packs_to_reduced_precision_datatype() {
4936        // C's N-bit codec (NDFileHDF5.cpp:3355-3357) narrows the dataset
4937        // datatype (H5Tset_precision/H5Tset_offset) and registers a
4938        // parameterless H5Pset_nbit filter. The standard write path reproduces
4939        // that with rust-hdf5 0.2.22: `DatasetBuilder::datatype` stores a
4940        // reduced-precision `FixedPoint` and `FilterPipeline::nbit` packs to it,
4941        // so the file is byte-readable by h5py/libhdf5. `build_pipeline` still
4942        // returns None for N-bit because the SWMR streaming builder cannot
4943        // override the datatype — packing is applied out of band by the standard
4944        // path only.
4945        let mut writer = Hdf5Writer::new();
4946        writer.set_compression_type(COMPRESS_NBIT);
4947        writer.set_nbit_precision(10);
4948        writer.set_nbit_offset(0);
4949        assert!(
4950            writer.build_pipeline(2).is_none(),
4951            "N-bit packing is applied via a datatype override, not build_pipeline"
4952        );
4953
4954        let path = temp_path("hdf5_nbit_packed");
4955        let mut arr = NDArray::new(
4956            vec![NDDimension::new(8), NDDimension::new(8)],
4957            NDDataType::UInt16,
4958        );
4959        if let NDDataBuffer::U16(ref mut v) = arr.data {
4960            for (i, x) in v.iter_mut().enumerate() {
4961                *x = (i as u16 * 7) & 0x3FF; // within the 10-bit range
4962            }
4963            v[0] = 0xFFFF; // above 10 bits: must pack to the low 10 bits (0x3FF)
4964        }
4965        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
4966        writer.write_file(&arr).unwrap();
4967        writer.close_file().unwrap();
4968
4969        let h5file = H5File::open(&path).unwrap();
4970        let ds = h5file.dataset("entry/instrument/detector/data").unwrap();
4971
4972        // The on-disk datatype carries the reduced precision (C-observable
4973        // narrower datatype) within the unchanged 2-byte footprint.
4974        match ds.datatype().unwrap() {
4975            DatatypeMessage::FixedPoint {
4976                size,
4977                bit_precision,
4978                bit_offset,
4979                signed,
4980                ..
4981            } => {
4982                assert_eq!(size, 2, "byte footprint unchanged");
4983                assert_eq!(bit_precision, 10, "precision narrowed to 10 bits");
4984                assert_eq!(bit_offset, 0);
4985                assert!(!signed, "u16 is unsigned");
4986            }
4987            other => panic!("expected reduced-precision FixedPoint, got {other:?}"),
4988        }
4989
4990        let data: Vec<u16> = ds.read_raw().unwrap();
4991        assert_eq!(data.len(), 8 * 8);
4992        // Real bit-packing: the over-range first element truncates to 10 bits.
4993        assert_eq!(data[0], 0x3FF, "0xFFFF must pack to the low 10 bits");
4994        for i in 1..data.len() {
4995            assert_eq!(
4996                data[i],
4997                (i as u16 * 7) & 0x3FF,
4998                "in-range value {i} round-trips"
4999            );
5000        }
5001        std::fs::remove_file(&path).ok();
5002    }
5003
5004    #[test]
5005    fn test_chunk_geometry_recorded() {
5006        // Requested row/col chunk geometry is recorded as dataset attributes
5007        // (the on-disk chunk is one frame per chunk — crate limitation).
5008        let path = temp_path("hdf5_chunkgeom");
5009        let mut writer = Hdf5Writer::new();
5010        writer.set_chunk_size_auto(false);
5011        writer.set_n_row_chunks(4);
5012        writer.set_n_col_chunks(2);
5013        writer.set_n_frames_chunks(3);
5014
5015        let mut arr = NDArray::new(
5016            vec![NDDimension::new(8), NDDimension::new(8)],
5017            NDDataType::UInt16,
5018        );
5019        if let NDDataBuffer::U16(ref mut v) = arr.data {
5020            for i in 0..v.len() {
5021                v[i] = i as u16;
5022            }
5023        }
5024
5025        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5026        writer.write_file(&arr).unwrap();
5027        writer.write_file(&arr).unwrap();
5028        writer.close_file().unwrap();
5029
5030        let h5 = H5File::open(&path).unwrap();
5031        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
5032        assert_eq!(ds.shape(), vec![2, 8, 8]);
5033        // Data still round-trips correctly through the per-frame chunks.
5034        let data: Vec<u16> = ds.read_raw().unwrap();
5035        assert_eq!(data.len(), 2 * 64);
5036        for i in 0..64usize {
5037            assert_eq!(data[i], i as u16, "frame0 element {}", i);
5038            assert_eq!(data[64 + i], i as u16, "frame1 element {}", i);
5039        }
5040        // Requested geometry preserved as attributes.
5041        assert_eq!(
5042            ds.attr("HDF5_nRowChunks")
5043                .unwrap()
5044                .read_numeric::<i32>()
5045                .unwrap(),
5046            4
5047        );
5048        assert_eq!(
5049            ds.attr("HDF5_nColChunks")
5050                .unwrap()
5051                .read_numeric::<i32>()
5052                .unwrap(),
5053            2
5054        );
5055        assert_eq!(
5056            ds.attr("HDF5_nFramesChunks")
5057                .unwrap()
5058                .read_numeric::<i32>()
5059                .unwrap(),
5060            3
5061        );
5062
5063        std::fs::remove_file(&path).ok();
5064    }
5065
5066    #[test]
5067    fn test_extra_dimensions_layout() {
5068        // HDF5_nExtraDims=2 builds 2+1 fixed leading axes. With the param sizes
5069        // extraDimSizeN(=eds[0])=2, extraDimSizeX(=eds[1])=3, extraDimSizeY(=
5070        // eds[2])=4 the dataspace is rank-5 `{Y, X, N, frameY, frameX}` =
5071        // [4,3,2,4,4] — C `NDFileHDF5::configureDims` order
5072        // (NDFileHDF5.cpp:3121-3230; docs/ADCore/NDFileHDF5.rst:379-380). The
5073        // frame data is row-major identical to the collapsed [24,4,4] form
5074        // (the innermost leading axis "N" varies fastest), so frame `f` lands
5075        // at flat leading index `f`.
5076        let path = temp_path("hdf5_extradims");
5077        let mut writer = Hdf5Writer::new();
5078        writer.set_n_extra_dims(2);
5079        writer.set_extra_dim_size(0, 2); // N: frames per point
5080        writer.set_extra_dim_size(1, 3); // X
5081        writer.set_extra_dim_size(2, 4); // Y
5082        writer.set_extra_dim_name(0, "n");
5083        writer.set_extra_dim_name(1, "scanX");
5084        writer.set_extra_dim_name(2, "scanY");
5085
5086        let mut arr = NDArray::new(
5087            vec![NDDimension::new(4), NDDimension::new(4)],
5088            NDDataType::UInt16,
5089        );
5090        for f in 0..24u16 {
5091            if let NDDataBuffer::U16(ref mut v) = arr.data {
5092                for x in v.iter_mut() {
5093                    *x = f;
5094                }
5095            }
5096            if f == 0 {
5097                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5098            }
5099            writer.write_file(&arr).unwrap();
5100        }
5101        writer.close_file().unwrap();
5102
5103        let h5 = H5File::open(&path).unwrap();
5104        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
5105        // Rank-5 multi-extra-dimension dataspace: [Y, X, N, frameY, frameX].
5106        assert_eq!(ds.shape(), vec![4, 3, 2, 4, 4]);
5107        let data: Vec<u16> = ds.read_raw().unwrap();
5108        assert_eq!(data.len(), 24 * 16);
5109        for f in 0..24usize {
5110            for i in 0..16usize {
5111                assert_eq!(data[f * 16 + i], f as u16, "frame {} elem {}", f, i);
5112            }
5113        }
5114        // Extra-dim sizes/names still recorded as recovery attributes.
5115        assert_eq!(
5116            ds.attr("HDF5_nExtraDims")
5117                .unwrap()
5118                .read_numeric::<i32>()
5119                .unwrap(),
5120            2
5121        );
5122        assert_eq!(
5123            ds.attr("HDF5_extraDimSize0")
5124                .unwrap()
5125                .read_numeric::<i32>()
5126                .unwrap(),
5127            2
5128        );
5129        assert_eq!(
5130            ds.attr("HDF5_extraDimName0")
5131                .unwrap()
5132                .read_string()
5133                .unwrap(),
5134            "n"
5135        );
5136
5137        std::fs::remove_file(&path).ok();
5138    }
5139
5140    #[test]
5141    fn test_extra_dimensions_one_virtual() {
5142        // HDF5_nExtraDims=1 builds 1+1 fixed leading axes:
5143        // {X, N, frameY, frameX}. With eds[0]=N=2, eds[1]=X=3 the dataspace is
5144        // rank-4 [3,2,Y,X] (NDFileHDF5.rst:377-378). 6 frames, value==frame.
5145        let path = temp_path("hdf5_extradims_1");
5146        let mut writer = Hdf5Writer::new();
5147        writer.set_n_extra_dims(1);
5148        writer.set_extra_dim_size(0, 2); // N: frames per point
5149        writer.set_extra_dim_size(1, 3); // X
5150
5151        let mut arr = NDArray::new(
5152            vec![NDDimension::new(4), NDDimension::new(4)],
5153            NDDataType::UInt16,
5154        );
5155        for f in 0..6u16 {
5156            if let NDDataBuffer::U16(ref mut v) = arr.data {
5157                for x in v.iter_mut() {
5158                    *x = f;
5159                }
5160            }
5161            if f == 0 {
5162                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5163            }
5164            writer.write_file(&arr).unwrap();
5165        }
5166        writer.close_file().unwrap();
5167
5168        let h5 = H5File::open(&path).unwrap();
5169        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
5170        assert_eq!(ds.shape(), vec![3, 2, 4, 4]);
5171        let data: Vec<u16> = ds.read_raw().unwrap();
5172        assert_eq!(data.len(), 6 * 16);
5173        for f in 0..6usize {
5174            for i in 0..16usize {
5175                assert_eq!(data[f * 16 + i], f as u16, "frame {} elem {}", f, i);
5176            }
5177        }
5178
5179        std::fs::remove_file(&path).ok();
5180    }
5181
5182    #[test]
5183    fn test_swmr_extra_dimensions_grid_layout() {
5184        // SWMR mirror of `test_extra_dimensions_layout`: HDF5_nExtraDims=2 with
5185        // eds[0]=2,eds[1]=3,eds[2]=4 must build the same rank-5 fixed grid
5186        // [Y,X,N,frameY,frameX] = [4,3,2,4,4] as the standard path (C
5187        // configureDims), not the old collapsed single leading axis. Each frame
5188        // is placed at its odometer chunk position via write_chunk_at; frame `f`
5189        // lands at flat leading index `f` (innermost "N" varies fastest).
5190        let path = temp_path("hdf5_swmr_extradims");
5191        let mut writer = Hdf5Writer::new();
5192        writer.set_swmr_mode(true);
5193        writer.set_n_extra_dims(2);
5194        writer.set_extra_dim_size(0, 2); // N: frames per point
5195        writer.set_extra_dim_size(1, 3); // X
5196        writer.set_extra_dim_size(2, 4); // Y
5197
5198        let mut arr = NDArray::new(
5199            vec![NDDimension::new(4), NDDimension::new(4)],
5200            NDDataType::UInt16,
5201        );
5202        for f in 0..24u16 {
5203            if let NDDataBuffer::U16(ref mut v) = arr.data {
5204                for x in v.iter_mut() {
5205                    *x = f;
5206                }
5207            }
5208            if f == 0 {
5209                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5210            }
5211            writer.write_file(&arr).unwrap();
5212        }
5213        writer.close_file().unwrap();
5214
5215        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
5216        assert_eq!(
5217            reader
5218                .dataset_shape("entry/instrument/detector/data")
5219                .unwrap(),
5220            vec![4, 3, 2, 4, 4]
5221        );
5222        let data: Vec<u16> = reader
5223            .read_dataset("entry/instrument/detector/data")
5224            .unwrap();
5225        assert_eq!(data.len(), 24 * 16);
5226        for f in 0..24usize {
5227            for i in 0..16usize {
5228                assert_eq!(data[f * 16 + i], f as u16, "frame {} elem {}", f, i);
5229            }
5230        }
5231        std::fs::remove_file(&path).ok();
5232    }
5233
5234    #[test]
5235    fn test_swmr_extra_dimensions_grid_subtiled() {
5236        // SWMR grid with frame sub-tiling: HDF5_nExtraDims=1 (rank-4 [3,2,4,4])
5237        // and HDF5_nRowChunks=2 splits each 4-row frame into two row tiles, so
5238        // the grid write path emits two write_chunk_at tiles per frame. The
5239        // reassembled pixels must still be row-major-correct. 6 frames fill the
5240        // 3x2 grid exactly; value==frame.
5241        let path = temp_path("hdf5_swmr_extradims_tiled");
5242        let mut writer = Hdf5Writer::new();
5243        writer.set_swmr_mode(true);
5244        writer.set_n_extra_dims(1);
5245        writer.set_extra_dim_size(0, 2); // N
5246        writer.set_extra_dim_size(1, 3); // X
5247        writer.set_n_row_chunks(2); // split the 4-row frame into 2 tiles
5248
5249        let mut arr = NDArray::new(
5250            vec![NDDimension::new(4), NDDimension::new(4)],
5251            NDDataType::UInt16,
5252        );
5253        for f in 0..6u16 {
5254            if let NDDataBuffer::U16(ref mut v) = arr.data {
5255                // Distinct per-element values prove tile reassembly, not just a
5256                // constant: value = frame*100 + (row*4 + col).
5257                for (i, x) in v.iter_mut().enumerate() {
5258                    *x = f * 100 + i as u16;
5259                }
5260            }
5261            if f == 0 {
5262                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5263            }
5264            writer.write_file(&arr).unwrap();
5265        }
5266        writer.close_file().unwrap();
5267
5268        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
5269        assert_eq!(
5270            reader
5271                .dataset_shape("entry/instrument/detector/data")
5272                .unwrap(),
5273            vec![3, 2, 4, 4]
5274        );
5275        let data: Vec<u16> = reader
5276            .read_dataset("entry/instrument/detector/data")
5277            .unwrap();
5278        assert_eq!(data.len(), 6 * 16);
5279        for f in 0..6usize {
5280            for i in 0..16usize {
5281                assert_eq!(
5282                    data[f * 16 + i],
5283                    f as u16 * 100 + i as u16,
5284                    "frame {} elem {}",
5285                    f,
5286                    i
5287                );
5288            }
5289        }
5290        std::fs::remove_file(&path).ok();
5291    }
5292
5293    #[test]
5294    fn test_swmr_extra_dimensions_grid_partial_fill() {
5295        // SWMR grid written with fewer frames than the grid capacity: the fixed
5296        // extent stays [3,2,4,4] and the unwritten odometer positions read back
5297        // as the fill value (0). Boundary: partial scan, capacity 6, write 4.
5298        let path = temp_path("hdf5_swmr_extradims_partial");
5299        let mut writer = Hdf5Writer::new();
5300        writer.set_swmr_mode(true);
5301        writer.set_n_extra_dims(1);
5302        writer.set_extra_dim_size(0, 2); // N
5303        writer.set_extra_dim_size(1, 3); // X
5304
5305        let mut arr = NDArray::new(
5306            vec![NDDimension::new(4), NDDimension::new(4)],
5307            NDDataType::UInt16,
5308        );
5309        for f in 0..4u16 {
5310            if let NDDataBuffer::U16(ref mut v) = arr.data {
5311                for x in v.iter_mut() {
5312                    *x = f + 1; // non-zero so fill (0) is distinguishable
5313                }
5314            }
5315            if f == 0 {
5316                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5317            }
5318            writer.write_file(&arr).unwrap();
5319        }
5320        writer.close_file().unwrap();
5321
5322        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
5323        assert_eq!(
5324            reader
5325                .dataset_shape("entry/instrument/detector/data")
5326                .unwrap(),
5327            vec![3, 2, 4, 4]
5328        );
5329        let data: Vec<u16> = reader
5330            .read_dataset("entry/instrument/detector/data")
5331            .unwrap();
5332        assert_eq!(data.len(), 6 * 16);
5333        for f in 0..6usize {
5334            let expected = if f < 4 { f as u16 + 1 } else { 0 };
5335            for i in 0..16usize {
5336                assert_eq!(data[f * 16 + i], expected, "frame {} elem {}", f, i);
5337            }
5338        }
5339        std::fs::remove_file(&path).ok();
5340    }
5341
5342    #[test]
5343    fn test_swmr_streaming() {
5344        let path = temp_path("hdf5_swmr");
5345        let mut writer = Hdf5Writer::new();
5346        writer.set_swmr_mode(true);
5347        writer.set_flush_nth_frame(2);
5348
5349        let arr = NDArray::new(
5350            vec![NDDimension::new(8), NDDimension::new(8)],
5351            NDDataType::Float32,
5352        );
5353
5354        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5355        writer.write_file(&arr).unwrap();
5356        writer.write_file(&arr).unwrap(); // should trigger flush
5357        writer.write_file(&arr).unwrap();
5358        writer.close_file().unwrap();
5359
5360        assert_eq!(writer.frame_count(), 3);
5361
5362        // Read back via SwmrFileReader
5363        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
5364        let shape = reader
5365            .dataset_shape("entry/instrument/detector/data")
5366            .unwrap();
5367        assert_eq!(shape[0], 3); // 3 frames
5368        assert_eq!(shape[1], 8);
5369        assert_eq!(shape[2], 8);
5370
5371        let data: Vec<f32> = reader
5372            .read_dataset("entry/instrument/detector/data")
5373            .unwrap();
5374        assert_eq!(data.len(), 3 * 8 * 8);
5375
5376        std::fs::remove_file(&path).ok();
5377    }
5378
5379    #[test]
5380    fn test_swmr_compression_is_applied() {
5381        // rust-hdf5 0.2.15 exposes a filtered SWMR dataset constructor, so
5382        // SWMR + compression produces a genuinely compressed file — the
5383        // compression is NOT dropped, and the data round-trips.
5384        let path = temp_path("hdf5_swmr_comp");
5385        let mut writer = Hdf5Writer::new();
5386        writer.set_swmr_mode(true);
5387        writer.set_compression_type(COMPRESS_ZLIB);
5388
5389        let arr = NDArray::new(
5390            vec![NDDimension::new(8), NDDimension::new(8)],
5391            NDDataType::UInt16,
5392        );
5393        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5394        assert!(
5395            !writer.swmr_compression_dropped(),
5396            "SWMR+ZLIB must apply compression, not drop it"
5397        );
5398        writer.write_file(&arr).unwrap();
5399        writer.write_file(&arr).unwrap();
5400        writer.close_file().unwrap();
5401
5402        // The compressed SWMR dataset round-trips.
5403        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
5404        let shape = reader
5405            .dataset_shape("entry/instrument/detector/data")
5406            .unwrap();
5407        assert_eq!(shape, vec![2, 8, 8]);
5408        let data: Vec<u16> = reader
5409            .read_dataset("entry/instrument/detector/data")
5410            .unwrap();
5411        assert_eq!(data.len(), 2 * 8 * 8);
5412
5413        std::fs::remove_file(&path).ok();
5414    }
5415
5416    #[test]
5417    fn test_layout_xml_param() {
5418        // Valid and invalid layout XML drive layout_valid / layout_error.
5419        let mut writer = Hdf5Writer::new();
5420        let dir = std::env::temp_dir();
5421        let good = dir.join("adcore_layout_good.xml");
5422        std::fs::write(
5423            &good,
5424            r#"<hdf5_layout><group name="entry"><dataset name="data" source="detector" det_default="true"/></group></hdf5_layout>"#,
5425        )
5426        .unwrap();
5427        assert!(writer.set_layout_filename(good.to_str().unwrap()));
5428        assert!(writer.layout_valid);
5429        assert!(writer.layout_error.is_empty());
5430
5431        let bad = dir.join("adcore_layout_bad.xml");
5432        std::fs::write(&bad, r#"<not_a_layout/>"#).unwrap();
5433        assert!(!writer.set_layout_filename(bad.to_str().unwrap()));
5434        assert!(!writer.layout_valid);
5435        assert!(!writer.layout_error.is_empty());
5436
5437        std::fs::remove_file(&good).ok();
5438        std::fs::remove_file(&bad).ok();
5439    }
5440
5441    #[test]
5442    fn test_layout_xml_places_dataset_in_nested_tree() {
5443        // A valid layout XML must place the image dataset at the layout's
5444        // det_default path (C ADCore /entry/instrument/detector/data),
5445        // NDAttributes under the ndattr_default group, and the performance
5446        // dataset under the group holding the `timestamp` dataset — NOT flat
5447        // at the file root.
5448        let dir = std::env::temp_dir();
5449        let layout = dir.join("adcore_layout_nested.xml");
5450        std::fs::write(
5451            &layout,
5452            r#"<hdf5_layout>
5453              <group name="entry">
5454                <group name="instrument">
5455                  <group name="detector">
5456                    <dataset name="data" source="detector" det_default="true">
5457                      <attribute name="signal" source="constant" value="1" type="int"/>
5458                    </dataset>
5459                  </group>
5460                  <group name="NDAttributes" ndattr_default="true"/>
5461                  <group name="performance">
5462                    <dataset name="timestamp"/>
5463                  </group>
5464                </group>
5465              </group>
5466            </hdf5_layout>"#,
5467        )
5468        .unwrap();
5469
5470        let path = temp_path("hdf5_layout_nested");
5471        let mut writer = Hdf5Writer::new();
5472        writer.set_store_performance(true);
5473        assert!(
5474            writer.set_layout_filename(layout.to_str().unwrap()),
5475            "layout XML must parse: {}",
5476            writer.layout_error
5477        );
5478
5479        let mk = |fill: f64| {
5480            let mut arr = NDArray::new(
5481                vec![NDDimension::new(4), NDDimension::new(4)],
5482                NDDataType::UInt16,
5483            );
5484            arr.attributes.add(NDAttribute::new_static(
5485                "exposure",
5486                "",
5487                NDAttrSource::Driver,
5488                NDAttrValue::Float64(fill),
5489            ));
5490            arr
5491        };
5492
5493        let a0 = mk(0.5);
5494        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
5495        writer.write_file(&a0).unwrap();
5496        writer.write_file(&mk(0.75)).unwrap();
5497        writer.close_file().unwrap();
5498
5499        let h5 = H5File::open(&path).unwrap();
5500        let names = h5.dataset_names();
5501        // Image dataset at the nested layout path, NOT flat `data`.
5502        assert!(
5503            names.contains(&"entry/instrument/detector/data".to_string()),
5504            "image dataset must be at the nested layout path; got {:?}",
5505            names
5506        );
5507        assert!(
5508            !names.contains(&"data".to_string()),
5509            "must not also write a flat-root `data` dataset"
5510        );
5511        let img = h5.dataset("entry/instrument/detector/data").unwrap();
5512        assert_eq!(img.shape(), vec![2, 4, 4]);
5513        // Layout constant attribute materialised.
5514        assert_eq!(
5515            img.attr("signal").unwrap().read_numeric::<i64>().unwrap(),
5516            1
5517        );
5518        // NDAttribute dataset under the ndattr_default group.
5519        assert!(
5520            names.contains(&"entry/instrument/NDAttributes/exposure".to_string()),
5521            "NDAttribute dataset must be under the layout ndattr group; got {:?}",
5522            names
5523        );
5524        // Performance dataset under the layout's performance group.
5525        assert!(
5526            names.contains(&"entry/instrument/performance/timestamp".to_string()),
5527            "performance dataset must be under the layout group; got {:?}",
5528            names
5529        );
5530
5531        // Read-back resolves the nested dataset path.
5532        drop(h5);
5533        let mut reader = Hdf5Writer::new();
5534        assert!(reader.set_layout_filename(layout.to_str().unwrap()));
5535        reader.current_path = Some(path.clone());
5536        let read_arr = reader.read_file().unwrap();
5537        assert_eq!(read_arr.dims.len(), 3);
5538
5539        std::fs::remove_file(&path).ok();
5540        std::fs::remove_file(&layout).ok();
5541    }
5542
5543    /// Layout XML declaring two `<dataset source="detector">` nodes plus a
5544    /// `<global name="detector_data_destination">`. C `NDFileHDF5` creates
5545    /// every detector dataset up front (`detDataMap`) and routes each frame to
5546    /// the one named by the destination NDAttribute, defaulting to the
5547    /// `det_default` dataset for an absent or unknown value
5548    /// (NDFileHDF5.cpp:1449-1519). Frames must land in the right dataset and
5549    /// each dataset must extend to exactly the count it received.
5550    #[test]
5551    fn test_detector_data_destination_routes_by_attribute() {
5552        let dir = std::env::temp_dir();
5553        let layout = dir.join("adcore_layout_multidet.xml");
5554        std::fs::write(
5555            &layout,
5556            r#"<hdf5_layout>
5557              <global name="detector_data_destination" ndattribute="dest"/>
5558              <group name="entry">
5559                <dataset name="data1" source="detector" det_default="true"/>
5560                <dataset name="data2" source="detector"/>
5561              </group>
5562            </hdf5_layout>"#,
5563        )
5564        .unwrap();
5565
5566        let path = temp_path("hdf5_multidet_route");
5567        let mut writer = Hdf5Writer::new();
5568        // Focus the test on image routing; no attribute time-series datasets.
5569        writer.store_attributes = false;
5570        assert!(
5571            writer.set_layout_filename(layout.to_str().unwrap()),
5572            "layout XML must parse: {}",
5573            writer.layout_error
5574        );
5575
5576        // Each frame is a uniform 2x2 UInt16 whose value identifies it, with an
5577        // optional `dest` string attribute selecting the destination dataset.
5578        let mk = |val: u16, dest: Option<&str>| {
5579            let mut arr = NDArray::new(
5580                vec![NDDimension::new(2), NDDimension::new(2)],
5581                NDDataType::UInt16,
5582            );
5583            if let NDDataBuffer::U16(ref mut v) = arr.data {
5584                for p in v.iter_mut() {
5585                    *p = val;
5586                }
5587            }
5588            if let Some(d) = dest {
5589                arr.attributes.add(NDAttribute::new_static(
5590                    "dest",
5591                    "",
5592                    NDAttrSource::Driver,
5593                    NDAttrValue::String(d.to_string()),
5594                ));
5595            }
5596            arr
5597        };
5598
5599        // f0: no dest        -> default data1
5600        // f1: /entry/data2   -> data2
5601        // f2: /entry/data2   -> data2
5602        // f3: /nonexistent   -> unknown, falls back to default data1
5603        // f4: /entry/data1   -> explicit default data1
5604        let f0 = mk(10, None);
5605        writer.open_file(&path, NDFileMode::Stream, &f0).unwrap();
5606        writer.write_file(&f0).unwrap();
5607        writer.write_file(&mk(11, Some("/entry/data2"))).unwrap();
5608        writer.write_file(&mk(12, Some("/entry/data2"))).unwrap();
5609        writer.write_file(&mk(13, Some("/nonexistent"))).unwrap();
5610        writer.write_file(&mk(14, Some("/entry/data1"))).unwrap();
5611        writer.close_file().unwrap();
5612
5613        let h5 = H5File::open(&path).unwrap();
5614        let names = h5.dataset_names();
5615        assert!(
5616            names.contains(&"entry/data1".to_string())
5617                && names.contains(&"entry/data2".to_string()),
5618            "both detector datasets must exist; got {:?}",
5619            names
5620        );
5621
5622        // data1 received f0, f3, f4 (in write order); data2 received f1, f2.
5623        let d1 = h5.dataset("entry/data1").unwrap();
5624        assert_eq!(d1.shape(), vec![3, 2, 2], "default dataset extent");
5625        let v1: Vec<u16> = d1.read_raw().unwrap();
5626        assert_eq!(
5627            v1,
5628            vec![10, 10, 10, 10, 13, 13, 13, 13, 14, 14, 14, 14],
5629            "default dataset must hold the default-routed frames in order"
5630        );
5631
5632        let d2 = h5.dataset("entry/data2").unwrap();
5633        assert_eq!(d2.shape(), vec![2, 2, 2], "routed dataset extent");
5634        let v2: Vec<u16> = d2.read_raw().unwrap();
5635        assert_eq!(
5636            v2,
5637            vec![11, 11, 11, 11, 12, 12, 12, 12],
5638            "routed dataset must hold only the frames addressed to it"
5639        );
5640
5641        drop(h5);
5642        std::fs::remove_file(&path).ok();
5643        std::fs::remove_file(&layout).ok();
5644    }
5645
5646    /// A present-but-non-string `detector_data_destination` attribute aborts the
5647    /// write, matching C `getValue(NDAttrString,…)` returning `ND_ERROR` →
5648    /// `asynError` (NDFileHDF5.cpp:1465-1471).
5649    #[test]
5650    fn test_detector_data_destination_non_string_attribute_errors() {
5651        let dir = std::env::temp_dir();
5652        let layout = dir.join("adcore_layout_multidet_err.xml");
5653        std::fs::write(
5654            &layout,
5655            r#"<hdf5_layout>
5656              <global name="detector_data_destination" ndattribute="dest"/>
5657              <group name="entry">
5658                <dataset name="data1" source="detector" det_default="true"/>
5659                <dataset name="data2" source="detector"/>
5660              </group>
5661            </hdf5_layout>"#,
5662        )
5663        .unwrap();
5664
5665        let path = temp_path("hdf5_multidet_err");
5666        let mut writer = Hdf5Writer::new();
5667        writer.store_attributes = false;
5668        assert!(writer.set_layout_filename(layout.to_str().unwrap()));
5669
5670        let mut arr = NDArray::new(
5671            vec![NDDimension::new(2), NDDimension::new(2)],
5672            NDDataType::UInt16,
5673        );
5674        arr.attributes.add(NDAttribute::new_static(
5675            "dest",
5676            "",
5677            NDAttrSource::Driver,
5678            NDAttrValue::Float64(2.0),
5679        ));
5680
5681        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5682        assert!(
5683            writer.write_file(&arr).is_err(),
5684            "a non-string destination attribute must abort the write"
5685        );
5686        writer.close_file().ok();
5687
5688        std::fs::remove_file(&path).ok();
5689        std::fs::remove_file(&layout).ok();
5690    }
5691
5692    #[test]
5693    fn test_layout_hardlink_is_materialised() {
5694        // Regression for BUG 2: a `<hardlink>` declared in the layout XML must
5695        // produce a real HDF5 hard link in the written file. C ADCore
5696        // `NDFileHDF5::createHardLinks` walks the layout and calls
5697        // `H5Lcreate_hard`; without that, files written from a layout with a
5698        // `<hardlink>` silently lack the link.
5699        let dir = std::env::temp_dir();
5700        let layout = dir.join("adcore_layout_hardlink.xml");
5701        std::fs::write(
5702            &layout,
5703            r#"<hdf5_layout>
5704              <group name="entry">
5705                <group name="data">
5706                  <dataset name="data" source="detector" det_default="true"/>
5707                  <hardlink name="data_alias" target="/entry/data/data"/>
5708                </group>
5709              </group>
5710            </hdf5_layout>"#,
5711        )
5712        .unwrap();
5713
5714        let path = temp_path("hdf5_layout_hardlink");
5715        let mut writer = Hdf5Writer::new();
5716        assert!(
5717            writer.set_layout_filename(layout.to_str().unwrap()),
5718            "layout XML must parse: {}",
5719            writer.layout_error
5720        );
5721
5722        let arr = NDArray::new(
5723            vec![NDDimension::new(4), NDDimension::new(4)],
5724            NDDataType::UInt16,
5725        );
5726        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5727        writer.write_file(&arr).unwrap();
5728        writer.close_file().unwrap();
5729
5730        let h5 = H5File::open(&path).unwrap();
5731        let names = h5.dataset_names();
5732        // The primary dataset at its layout path.
5733        assert!(
5734            names.contains(&"entry/data/data".to_string()),
5735            "image dataset must exist at the layout path; got {:?}",
5736            names
5737        );
5738        // The hard link is an additional name resolving to the same object.
5739        assert!(
5740            names.contains(&"entry/data/data_alias".to_string()),
5741            "layout <hardlink> must be materialised as a hard link; got {:?}",
5742            names
5743        );
5744        // The link shares the target object: same shape, readable as a dataset.
5745        let alias = h5.dataset("entry/data/data_alias").unwrap();
5746        let orig = h5.dataset("entry/data/data").unwrap();
5747        assert_eq!(alias.shape(), orig.shape());
5748
5749        drop(h5);
5750        std::fs::remove_file(&path).ok();
5751        std::fs::remove_file(&layout).ok();
5752    }
5753
5754    #[test]
5755    fn test_swmr_layout_hardlink_is_materialised() {
5756        // A `<hardlink>` declared in the layout XML must also be materialised
5757        // for SWMR-mode files. C ADCore `NDFileHDF5.cpp:320`-`326` calls
5758        // `createHardLinks` before `startSWMR()`, so the link is committed by
5759        // `start_swmr()` and visible to SWMR readers for the whole streaming
5760        // window. The rust-hdf5 0.2.17 `SwmrFileWriter::create_hard_link` API
5761        // is called from `open_swmr` before `start_swmr()` — no close-path
5762        // re-open pass.
5763        //
5764        // SWMR mode now places the image dataset at the layout's nested
5765        // `det_default` path (`/entry/data/data`), exactly like standard mode;
5766        // the layout hardlink targets that nested path.
5767        let dir = std::env::temp_dir();
5768        let layout = dir.join("adcore_swmr_layout_hardlink.xml");
5769        std::fs::write(
5770            &layout,
5771            r#"<hdf5_layout>
5772              <group name="entry">
5773                <group name="data">
5774                  <dataset name="data" source="detector" det_default="true"/>
5775                  <hardlink name="data_alias" target="/entry/data/data"/>
5776                </group>
5777              </group>
5778            </hdf5_layout>"#,
5779        )
5780        .unwrap();
5781
5782        let path = temp_path("hdf5_swmr_layout_hardlink");
5783        let mut writer = Hdf5Writer::new();
5784        writer.set_swmr_mode(true);
5785        assert!(
5786            writer.set_layout_filename(layout.to_str().unwrap()),
5787            "layout XML must parse: {}",
5788            writer.layout_error
5789        );
5790
5791        let mut arr = NDArray::new(
5792            vec![NDDimension::new(4), NDDimension::new(4)],
5793            NDDataType::UInt16,
5794        );
5795        if let NDDataBuffer::U16(ref mut v) = arr.data {
5796            for (i, x) in v.iter_mut().enumerate() {
5797                *x = i as u16;
5798            }
5799        }
5800        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5801        assert!(
5802            writer.is_swmr_active(),
5803            "writer must be in SWMR mode for this test"
5804        );
5805        writer.write_file(&arr).unwrap();
5806        writer.write_file(&arr).unwrap();
5807        writer.close_file().unwrap();
5808
5809        let h5 = H5File::open(&path).unwrap();
5810        let names = h5.dataset_names();
5811        // The primary SWMR dataset at its nested layout path.
5812        assert!(
5813            names.contains(&"entry/data/data".to_string()),
5814            "SWMR image dataset must exist at the nested layout path; got {:?}",
5815            names
5816        );
5817        // The hard link materialised under the layout group.
5818        assert!(
5819            names.contains(&"entry/data/data_alias".to_string()),
5820            "SWMR layout <hardlink> must be materialised as a hard link; got {:?}",
5821            names
5822        );
5823        // The link shares the target object: same shape, readable as a dataset.
5824        let alias = h5.dataset("entry/data/data_alias").unwrap();
5825        let orig = h5.dataset("entry/data/data").unwrap();
5826        assert_eq!(alias.shape(), orig.shape());
5827        assert_eq!(orig.shape(), vec![2, 4, 4]);
5828
5829        drop(h5);
5830        std::fs::remove_file(&path).ok();
5831        std::fs::remove_file(&layout).ok();
5832    }
5833
5834    #[test]
5835    fn test_swmr_layout_nested_dataset_placement() {
5836        // SWMR mode must place the image dataset at the layout's nested
5837        // `det_default` path — mirroring C `NDFileHDF5` createTree
5838        // (`NDFileHDF5.cpp:638`) which builds the group tree and creates the
5839        // detector dataset inside it. The nested dataset, the layout
5840        // `<hardlink>`, and a constant dataset attribute must all be visible
5841        // to a `SwmrFileReader` reading the file back.
5842        let dir = std::env::temp_dir();
5843        let layout = dir.join("adcore_swmr_layout_nested.xml");
5844        std::fs::write(
5845            &layout,
5846            r#"<hdf5_layout>
5847              <group name="entry">
5848                <group name="instrument">
5849                  <group name="detector">
5850                    <dataset name="data" source="detector" det_default="true">
5851                      <attribute name="signal" source="constant" value="1" type="int"/>
5852                    </dataset>
5853                    <hardlink name="data_alias" target="/entry/instrument/detector/data"/>
5854                  </group>
5855                </group>
5856                <group name="empty_placeholder"/>
5857              </group>
5858            </hdf5_layout>"#,
5859        )
5860        .unwrap();
5861
5862        let path = temp_path("hdf5_swmr_layout_nested");
5863        let mut writer = Hdf5Writer::new();
5864        writer.set_swmr_mode(true);
5865        assert!(
5866            writer.set_layout_filename(layout.to_str().unwrap()),
5867            "layout XML must parse: {}",
5868            writer.layout_error
5869        );
5870
5871        let mut arr = NDArray::new(
5872            vec![NDDimension::new(4), NDDimension::new(4)],
5873            NDDataType::UInt16,
5874        );
5875        if let NDDataBuffer::U16(ref mut v) = arr.data {
5876            for (i, x) in v.iter_mut().enumerate() {
5877                *x = (i * 3) as u16;
5878            }
5879        }
5880        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
5881        assert!(
5882            writer.is_swmr_active(),
5883            "writer must be in SWMR mode for this test"
5884        );
5885        writer.write_file(&arr).unwrap();
5886        writer.write_file(&arr).unwrap();
5887        writer.close_file().unwrap();
5888
5889        // Read back via the SWMR reader — these are the exact paths a live
5890        // reader attaching during the streaming window would resolve.
5891        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
5892        let names = reader.dataset_names();
5893        // Image dataset at the nested layout path, NOT flat `data`.
5894        assert!(
5895            names.contains(&"entry/instrument/detector/data".to_string()),
5896            "SWMR image dataset must live at the nested layout path; got {:?}",
5897            names
5898        );
5899        assert!(
5900            !names.contains(&"data".to_string()),
5901            "SWMR image dataset must NOT remain at the flat root; got {:?}",
5902            names
5903        );
5904        // The empty placeholder group exists.
5905        assert!(
5906            reader.has_group("entry/empty_placeholder"),
5907            "empty layout group must be materialised; groups {:?}",
5908            reader.group_paths()
5909        );
5910        // The layout `<hardlink>` resolves to the nested dataset.
5911        assert!(
5912            names.contains(&"entry/instrument/detector/data_alias".to_string()),
5913            "SWMR layout <hardlink> must resolve to the nested dataset; got {:?}",
5914            names
5915        );
5916        let nested = reader
5917            .dataset_shape("entry/instrument/detector/data")
5918            .unwrap();
5919        let alias = reader
5920            .dataset_shape("entry/instrument/detector/data_alias")
5921            .unwrap();
5922        assert_eq!(nested, vec![2, 4, 4]);
5923        assert_eq!(alias, nested, "hardlink alias must share the target shape");
5924        // The data round-trips through both names.
5925        let via_nested: Vec<u16> = reader
5926            .read_dataset("entry/instrument/detector/data")
5927            .unwrap();
5928        let via_alias: Vec<u16> = reader
5929            .read_dataset("entry/instrument/detector/data_alias")
5930            .unwrap();
5931        assert_eq!(via_nested, via_alias);
5932        assert_eq!(via_nested.len(), 2 * 4 * 4);
5933        // The constant layout dataset attribute and the C-parity NDArray default
5934        // attributes (writeDefaultDatasetAttributes, NDFileHDF5.cpp:3695-3719) all
5935        // materialised before start_swmr().
5936        let attr_names = reader
5937            .dataset_attr_names("entry/instrument/detector/data")
5938            .unwrap();
5939        for expected in [
5940            "signal",
5941            "NDArrayNumDims",
5942            "NDArrayDimOffset",
5943            "NDArrayDimBinning",
5944            "NDArrayDimReverse",
5945        ] {
5946            assert!(
5947                attr_names.iter().any(|n| n == expected),
5948                "streaming dataset must carry the {expected} attribute; got {attr_names:?}",
5949            );
5950        }
5951
5952        drop(reader);
5953        std::fs::remove_file(&path).ok();
5954        std::fs::remove_file(&layout).ok();
5955    }
5956
5957    #[test]
5958    fn test_default_layout_parses_and_resolves_nexus_paths() {
5959        // Guards DEFAULT_LAYOUT_XML + the `default_layout()` expect(): the
5960        // built-in layout must parse and resolve C's NeXus placements
5961        // (NDFileHDF5LayoutXML.cpp:43-70).
5962        let layout = Hdf5Writer::default_layout();
5963        assert_eq!(
5964            layout.detector_dataset_path().as_deref(),
5965            Some("/entry/instrument/detector/data")
5966        );
5967        assert_eq!(
5968            layout.ndattr_default_group().as_deref(),
5969            Some("/entry/instrument/NDAttributes")
5970        );
5971        assert_eq!(
5972            layout.dataset_group_path("timestamp").as_deref(),
5973            Some("/entry/instrument/performance")
5974        );
5975    }
5976
5977    #[test]
5978    fn test_no_layout_uses_default_nexus_layout() {
5979        // With no layout file the writer loads C's built-in DEFAULT_LAYOUT
5980        // (NDFileHDF5LayoutXML.cpp:43-70): the detector image lands at
5981        // /entry/instrument/detector/data inside the NeXus tree, with an
5982        // /entry/data/data hardlink to it — not a flat-root `data`.
5983        let path = temp_path("hdf5_default_layout");
5984        let mut writer = Hdf5Writer::new();
5985        let arr = NDArray::new(
5986            vec![NDDimension::new(4), NDDimension::new(4)],
5987            NDDataType::UInt8,
5988        );
5989        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
5990        writer.write_file(&arr).unwrap();
5991        writer.close_file().unwrap();
5992
5993        let h5 = H5File::open(&path).unwrap();
5994        // Detector dataset at the NeXus path, not flat root.
5995        let det = h5.dataset("entry/instrument/detector/data").unwrap();
5996        assert_eq!(det.shape(), vec![1, 4, 4]);
5997        assert!(
5998            !h5.dataset_names().contains(&"data".to_string()),
5999            "must not write a flat-root `data`; got {:?}",
6000            h5.dataset_names()
6001        );
6002        // The NXdata hardlink /entry/data/data resolves to the same image.
6003        let linked = h5.dataset("entry/data/data").unwrap();
6004        assert_eq!(linked.shape(), vec![1, 4, 4]);
6005        std::fs::remove_file(&path).ok();
6006    }
6007
6008    #[test]
6009    fn test_default_layout_group_nx_class_attributes() {
6010        // C attaches NX_class markers to the default-layout NeXus groups via
6011        // writeHdfAttributes (NDFileHDF5.cpp:693-695): NXentry/NXinstrument/
6012        // NXdetector/NXcollection/NXdata.
6013        let path = temp_path("hdf5_nxclass");
6014        let mut writer = Hdf5Writer::new();
6015        let arr = NDArray::new(
6016            vec![NDDimension::new(4), NDDimension::new(4)],
6017            NDDataType::UInt8,
6018        );
6019        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
6020        writer.write_file(&arr).unwrap();
6021        writer.close_file().unwrap();
6022
6023        let h5 = H5File::open(&path).unwrap();
6024        let nx = |g: &str| {
6025            let mut grp = h5.root_group();
6026            for seg in g.split('/') {
6027                grp = grp.group(seg).unwrap();
6028            }
6029            grp.attr_string("NX_class").unwrap()
6030        };
6031        assert_eq!(nx("entry"), "NXentry");
6032        assert_eq!(nx("entry/instrument"), "NXinstrument");
6033        assert_eq!(nx("entry/instrument/detector"), "NXdetector");
6034        assert_eq!(nx("entry/instrument/NDAttributes"), "NXcollection");
6035        assert_eq!(nx("entry/instrument/detector/NDAttributes"), "NXcollection");
6036        assert_eq!(nx("entry/data"), "NXdata");
6037        std::fs::remove_file(&path).ok();
6038    }
6039
6040    #[test]
6041    fn test_swmr_default_layout_group_nx_class_attributes() {
6042        // The SWMR path materialises the same NX_class group markers
6043        // (build_swmr_layout_groups → write_swmr_group_constant_attrs).
6044        let path = temp_path("hdf5_swmr_nxclass");
6045        let mut writer = Hdf5Writer::new();
6046        writer.set_swmr_mode(true);
6047        let arr = NDArray::new(
6048            vec![NDDimension::new(4), NDDimension::new(4)],
6049            NDDataType::Float32,
6050        );
6051        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
6052        writer.write_file(&arr).unwrap();
6053        writer.close_file().unwrap();
6054
6055        let h5 = H5File::open(&path).unwrap();
6056        let nx = |g: &str| {
6057            let mut grp = h5.root_group();
6058            for seg in g.split('/') {
6059                grp = grp.group(seg).unwrap();
6060            }
6061            grp.attr_string("NX_class").unwrap()
6062        };
6063        assert_eq!(nx("entry"), "NXentry");
6064        assert_eq!(nx("entry/instrument/detector"), "NXdetector");
6065        assert_eq!(nx("entry/data"), "NXdata");
6066        std::fs::remove_file(&path).ok();
6067    }
6068
6069    // ---- ADP-99: direct chunk write of pre-compressed NDArrays -----------
6070
6071    /// An uncompressed UInt16 frame with a deterministic ramp, ready to feed a
6072    /// codec. `dims = [y, x]` (HDF5 fastest axis last).
6073    fn ramp_u16(y: usize, x: usize) -> NDArray {
6074        let data: Vec<u16> = (0..(y * x) as u16).collect();
6075        NDArray::with_data(
6076            vec![NDDimension::new(y), NDDimension::new(x)],
6077            NDDataBuffer::U16(data),
6078        )
6079    }
6080
6081    fn u16_pixels(arr: &NDArray) -> Vec<u16> {
6082        match &arr.data {
6083            NDDataBuffer::U16(v) => v.clone(),
6084            _ => unreachable!("expected U16 buffer"),
6085        }
6086    }
6087
6088    #[test]
6089    fn test_codec_chunk_bytes_lz4_prepends_hdf5_header() {
6090        // C NDFileHDF5Dataset::writeFile (NDFileHDF5Dataset.cpp:299-314): a raw
6091        // LZ4 block gets a 16-byte big-endian header — uncompressed size (u64),
6092        // block size = uncompressed size (u32), compressed size (u32) — then the
6093        // block bytes.
6094        let codec = Codec {
6095            name: CodecName::LZ4,
6096            compressed_size: 4,
6097            level: 0,
6098            shuffle: 0,
6099            compressor: 0,
6100            original_data_type: NDDataType::UInt16,
6101        };
6102        let payload = [0xAAu8, 0xBB, 0xCC, 0xDD];
6103        let out = codec_chunk_bytes(&codec, 32, &payload);
6104        let mut expect = Vec::new();
6105        expect.extend_from_slice(&32u64.to_be_bytes()); // uncompressed size
6106        expect.extend_from_slice(&32u32.to_be_bytes()); // block size
6107        expect.extend_from_slice(&4u32.to_be_bytes()); // compressed size
6108        expect.extend_from_slice(&payload);
6109        assert_eq!(out, expect);
6110    }
6111
6112    #[test]
6113    fn test_codec_chunk_bytes_verbatim_for_self_describing() {
6114        // BLOSC and JPEG emit self-describing streams, so the chunk is written
6115        // verbatim — no extra header. (LZ4 and BSLZ4 get a chunk header; see
6116        // test_direct_chunk_write_lz4_roundtrips / test_codec_chunk_bytes_bslz4_header.)
6117        for name in [CodecName::Blosc, CodecName::JPEG] {
6118            let codec = Codec {
6119                name,
6120                compressed_size: 3,
6121                level: 0,
6122                shuffle: 0,
6123                compressor: 0,
6124                original_data_type: NDDataType::UInt8,
6125            };
6126            let payload = [1u8, 2, 3];
6127            assert_eq!(codec_chunk_bytes(&codec, 99, &payload), payload.to_vec());
6128        }
6129    }
6130
6131    #[test]
6132    fn test_codecs_match() {
6133        let a = Codec {
6134            name: CodecName::LZ4,
6135            compressed_size: 1,
6136            level: 0,
6137            shuffle: 0,
6138            compressor: 0,
6139            original_data_type: NDDataType::UInt8,
6140        };
6141        assert!(codecs_match(None, None));
6142        assert!(!codecs_match(Some(&a), None));
6143        assert!(!codecs_match(None, Some(&a)));
6144        assert!(codecs_match(Some(&a), Some(&a.clone())));
6145        let mut diff = a.clone();
6146        diff.compressor = 1;
6147        assert!(!codecs_match(Some(&a), Some(&diff)));
6148        // original_data_type is NOT part of codec identity (C Codec_t::operator!=).
6149        let mut other_type = a.clone();
6150        other_type.original_data_type = NDDataType::Float64;
6151        assert!(codecs_match(Some(&a), Some(&other_type)));
6152    }
6153
6154    #[test]
6155    fn test_codec_filter_pipeline_ids() {
6156        let w = Hdf5Writer::new();
6157        let mk = |name| Codec {
6158            name,
6159            compressed_size: 0,
6160            level: 5,
6161            shuffle: 1,
6162            compressor: 2,
6163            original_data_type: NDDataType::UInt16,
6164        };
6165        assert_eq!(
6166            w.codec_filter_pipeline(&mk(CodecName::LZ4))
6167                .unwrap()
6168                .filters[0]
6169                .id,
6170            FILTER_LZ4
6171        );
6172        assert_eq!(
6173            w.codec_filter_pipeline(&mk(CodecName::BSLZ4))
6174                .unwrap()
6175                .filters[0]
6176                .id,
6177            FILTER_BSHUF
6178        );
6179        let blosc = w.codec_filter_pipeline(&mk(CodecName::Blosc)).unwrap();
6180        assert_eq!(blosc.filters[0].id, FILTER_BLOSC);
6181        // C copies the array's own blosc level/shuffle/compressor into the
6182        // dataset filter (configureCompression NDFileHDF5.cpp:3320-3323).
6183        assert_eq!(blosc.filters[0].cd_values[4], 5, "level");
6184        assert_eq!(blosc.filters[0].cd_values[5], 1, "shuffle");
6185        assert_eq!(blosc.filters[0].cd_values[6], 2, "compressor");
6186        assert_eq!(
6187            w.codec_filter_pipeline(&mk(CodecName::JPEG))
6188                .unwrap()
6189                .filters[0]
6190                .id,
6191            FILTER_JPEG
6192        );
6193        // Codecs C does not direct-chunk-write are rejected (None pipeline).
6194        assert!(w.codec_filter_pipeline(&mk(CodecName::None)).is_none());
6195        assert!(w.codec_filter_pipeline(&mk(CodecName::Zlib)).is_none());
6196        assert!(w.codec_filter_pipeline(&mk(CodecName::LZ4HDF5)).is_none());
6197    }
6198
6199    #[test]
6200    fn test_compression_aware_true() {
6201        // C NDFileHDF5.cpp:2268 passes compressionAware=true.
6202        assert!(Hdf5FileProcessor::new().compression_aware());
6203    }
6204
6205    #[test]
6206    fn test_direct_chunk_write_lz4_roundtrips() {
6207        let path = temp_path("hdf5_dcw_lz4");
6208        let mut writer = Hdf5Writer::new();
6209        let orig = ramp_u16(4, 5);
6210        let expect = u16_pixels(&orig);
6211        let comp = crate::codec::compress_lz4(&orig);
6212        assert!(comp.codec.is_some());
6213        writer.open_file(&path, NDFileMode::Single, &comp).unwrap();
6214        writer.write_file(&comp).unwrap();
6215        writer.close_file().unwrap();
6216
6217        let h5 = H5File::open(&path).unwrap();
6218        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
6219        // HDF5 axes are the NDArray dims reversed (fastest-varying last), so a
6220        // [4, 5] frame is stored as [1, 5, 4]; the flat pixel buffer order is
6221        // unchanged and round-trips through the reversed filter intact.
6222        assert_eq!(ds.shape(), vec![1, 5, 4]);
6223        assert!(ds.is_chunked());
6224        let read = ds.read_raw::<u16>().unwrap();
6225        assert_eq!(
6226            read, expect,
6227            "LZ4 direct-chunk-write must reverse to the original pixels"
6228        );
6229        std::fs::remove_file(&path).ok();
6230    }
6231
6232    #[test]
6233    fn test_direct_chunk_write_blosc_roundtrips() {
6234        let path = temp_path("hdf5_dcw_blosc");
6235        let mut writer = Hdf5Writer::new();
6236        let orig = ramp_u16(4, 5);
6237        let expect = u16_pixels(&orig);
6238        let comp = crate::codec::compress_blosc(&orig, &crate::codec::BloscConfig::default());
6239        assert_eq!(comp.codec.as_ref().unwrap().name, CodecName::Blosc);
6240        writer.open_file(&path, NDFileMode::Single, &comp).unwrap();
6241        writer.write_file(&comp).unwrap();
6242        writer.close_file().unwrap();
6243
6244        let h5 = H5File::open(&path).unwrap();
6245        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
6246        assert_eq!(ds.shape(), vec![1, 5, 4]);
6247        let read = ds.read_raw::<u16>().unwrap();
6248        assert_eq!(read, expect, "BLOSC direct-chunk-write must round-trip");
6249        std::fs::remove_file(&path).ok();
6250    }
6251
6252    #[test]
6253    fn test_direct_chunk_write_bslz4_roundtrips() {
6254        // `compress_bslz4` emits the canonical bitshuffle+LZ4 on-disk format
6255        // (byte-for-byte the libhdf5/h5py/C-areaDetector bytes — locked by
6256        // codec::tests::test_bitshuffle_matches_c_reference_vector), stored
6257        // verbatim with the 12-byte chunk header (test_codec_chunk_bytes_bslz4_header).
6258        // rust-hdf5 0.2.21's bitshuffle reverse filter is canonical (LSB-first),
6259        // so it reads those bytes back to the original pixels. Use a whole-block
6260        // frame: u16 blocks are 4096 elems, so 128*128 = 16384 = 4 full blocks
6261        // with no partial tail.
6262        let path = temp_path("hdf5_dcw_bslz4");
6263        let mut writer = Hdf5Writer::new();
6264        let orig = ramp_u16(128, 128);
6265        let expect = u16_pixels(&orig);
6266        let comp = crate::codec::compress_bslz4(&orig);
6267        assert_eq!(comp.codec.as_ref().unwrap().name, CodecName::BSLZ4);
6268        writer.open_file(&path, NDFileMode::Single, &comp).unwrap();
6269        writer.write_file(&comp).unwrap();
6270        writer.close_file().unwrap();
6271
6272        let h5 = H5File::open(&path).unwrap();
6273        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
6274        assert_eq!(ds.shape(), vec![1, 128, 128]);
6275        assert!(ds.is_chunked());
6276        assert_eq!(ds.element_size(), 2, "dataset keeps the original u16 type");
6277        let read = ds.read_raw::<u16>().unwrap();
6278        assert_eq!(read, expect, "BSLZ4 direct-chunk-write must round-trip");
6279        std::fs::remove_file(&path).ok();
6280    }
6281
6282    #[test]
6283    fn test_codec_chunk_bytes_bslz4_header() {
6284        // The HDF5 bitshuffle filter expects a 12-byte big-endian chunk header
6285        // ahead of the canonical stream: uncompressed total bytes (u64) and the
6286        // block size in bytes (u32 = block_elems * elem_size, C hardcodes the
6287        // 8192 default), per NDFileHDF5Dataset::writeFile (cpp:316-328).
6288        let codec = Codec {
6289            name: CodecName::BSLZ4,
6290            compressed_size: 0,
6291            level: 0,
6292            shuffle: 0,
6293            compressor: 0,
6294            original_data_type: NDDataType::UInt16,
6295        };
6296        let payload = [0xDEu8, 0xAD, 0xBE, 0xEF];
6297        let total = 128 * 128 * 2; // one u16 frame
6298        let out = codec_chunk_bytes(&codec, total, &payload);
6299        assert_eq!(&out[0..8], &(total as u64).to_be_bytes());
6300        // u16 default block is 4096 elems => 8192 bytes (matches C's 8192).
6301        assert_eq!(&out[8..12], &8192u32.to_be_bytes());
6302        assert_eq!(&out[12..], &payload, "canonical stream follows verbatim");
6303    }
6304
6305    #[test]
6306    fn test_direct_chunk_write_lz4_multiframe_extends_leading_axis() {
6307        let path = temp_path("hdf5_dcw_lz4_multi");
6308        let mut writer = Hdf5Writer::new();
6309        let frames: Vec<NDArray> = (0..3u16)
6310            .map(|f| {
6311                let data: Vec<u16> = (0..20u16).map(|i| i + f * 100).collect();
6312                NDArray::with_data(
6313                    vec![NDDimension::new(4), NDDimension::new(5)],
6314                    NDDataBuffer::U16(data),
6315                )
6316            })
6317            .collect();
6318        let comp: Vec<NDArray> = frames.iter().map(crate::codec::compress_lz4).collect();
6319        writer
6320            .open_file(&path, NDFileMode::Stream, &comp[0])
6321            .unwrap();
6322        for c in &comp {
6323            writer.write_file(c).unwrap();
6324        }
6325        writer.close_file().unwrap();
6326
6327        let h5 = H5File::open(&path).unwrap();
6328        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
6329        assert_eq!(ds.shape(), vec![3, 5, 4], "three compressed frames stacked");
6330        let read = ds.read_raw::<u16>().unwrap();
6331        let mut expect = Vec::new();
6332        for f in 0..3u16 {
6333            for i in 0..20u16 {
6334                expect.push(i + f * 100);
6335            }
6336        }
6337        assert_eq!(read, expect, "every frame's pixels recovered in order");
6338        std::fs::remove_file(&path).ok();
6339    }
6340
6341    #[test]
6342    fn test_direct_chunk_write_jpeg_records_chunked_dataset() {
6343        // JPEG is lossy and the rust-hdf5 reader has no JPEG reverse filter, so
6344        // pixels cannot round-trip; verify the dataset is created with the right
6345        // original type/shape and chunked (one whole frame per chunk). The JPEG
6346        // filter id is covered by test_codec_filter_pipeline_ids.
6347        let path = temp_path("hdf5_dcw_jpeg");
6348        let mut writer = Hdf5Writer::new();
6349        let mono: Vec<u8> = (0..64u16).map(|i| (i * 3) as u8).collect();
6350        let src = NDArray::with_data(
6351            vec![NDDimension::new(8), NDDimension::new(8)],
6352            NDDataBuffer::U8(mono),
6353        );
6354        let comp = crate::codec::compress_jpeg(&src, 90).expect("jpeg encode");
6355        assert_eq!(comp.codec.as_ref().unwrap().name, CodecName::JPEG);
6356        writer.open_file(&path, NDFileMode::Single, &comp).unwrap();
6357        writer.write_file(&comp).unwrap();
6358        writer.close_file().unwrap();
6359
6360        let h5 = H5File::open(&path).unwrap();
6361        let ds = h5.dataset("entry/instrument/detector/data").unwrap();
6362        assert_eq!(ds.shape(), vec![1, 8, 8]);
6363        assert!(ds.is_chunked());
6364        std::fs::remove_file(&path).ok();
6365    }
6366
6367    #[test]
6368    fn test_codec_change_mid_stream_errors() {
6369        // C verifyChunking rejects a frame whose codec differs from the dataset's
6370        // (NDFileHDF5Dataset.cpp:194-200). Here the file is opened compressed and
6371        // a later uncompressed frame must be refused.
6372        let path = temp_path("hdf5_codec_change");
6373        let mut writer = Hdf5Writer::new();
6374        let f0 = crate::codec::compress_lz4(&ramp_u16(4, 5));
6375        let f1_plain = ramp_u16(4, 5); // no codec
6376        writer.open_file(&path, NDFileMode::Stream, &f0).unwrap();
6377        writer.write_file(&f0).unwrap();
6378        assert!(
6379            writer.write_file(&f1_plain).is_err(),
6380            "an uncompressed frame must be rejected for a compressed dataset"
6381        );
6382        writer.close_file().ok();
6383        std::fs::remove_file(&path).ok();
6384    }
6385
6386    #[test]
6387    fn test_swmr_rejects_compressed_array() {
6388        // SWMR mode has no raw-chunk append API, so a pre-compressed frame is
6389        // rejected rather than re-compressed into garbage (documented residual).
6390        let path = temp_path("hdf5_swmr_compressed");
6391        let mut writer = Hdf5Writer::new();
6392        writer.set_swmr_mode(true);
6393        let comp = crate::codec::compress_lz4(&ramp_u16(4, 5));
6394        writer.open_file(&path, NDFileMode::Stream, &comp).unwrap();
6395        assert!(
6396            writer.write_file(&comp).is_err(),
6397            "SWMR mode cannot direct-chunk-write a pre-compressed array"
6398        );
6399        writer.close_file().ok();
6400        std::fs::remove_file(&path).ok();
6401    }
6402
6403    #[test]
6404    fn test_unsupported_codec_rejected_on_open() {
6405        // A Zlib-compressed array has no C direct-chunk-write analog; dataset
6406        // creation rejects it rather than writing an unreadable file.
6407        let path = temp_path("hdf5_dcw_zlib");
6408        let mut writer = Hdf5Writer::new();
6409        let comp = crate::codec::compress_zlib(&ramp_u16(4, 5));
6410        assert_eq!(comp.codec.as_ref().unwrap().name, CodecName::Zlib);
6411        writer.open_file(&path, NDFileMode::Single, &comp).unwrap();
6412        assert!(
6413            writer.write_file(&comp).is_err(),
6414            "an unsupported (zlib) codec must be rejected, not silently mis-written"
6415        );
6416        writer.close_file().ok();
6417        std::fs::remove_file(&path).ok();
6418    }
6419}