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