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