Skip to main content

ad_plugins_rs/
file_hdf5.rs

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