Skip to main content

ad_plugins_rs/
file_hdf5.rs

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