Skip to main content

ad_plugins_rs/
file_hdf5.rs

1use std::path::{Path, PathBuf};
2
3use ad_core_rs::attributes::{NDAttrDataType, NDAttrValue};
4use ad_core_rs::error::{ADError, ADResult};
5use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
8use ad_core_rs::plugin::file_controller::FilePluginController;
9use ad_core_rs::plugin::runtime::{
10    NDPluginProcess, ParamChangeResult, ParamUpdate, PluginParamSnapshot, ProcessResult,
11};
12
13use rust_hdf5::H5File;
14use rust_hdf5::format::messages::filter::{
15    FILTER_BLOSC, FILTER_BSHUF, FILTER_JPEG, FILTER_NBIT, FILTER_SZIP, Filter, FilterPipeline,
16};
17use rust_hdf5::swmr::SwmrFileWriter;
18
19use crate::hdf5_layout::Hdf5Layout;
20
21/// C ADCore compression type enum values (matching NDFileHDF5.h).
22const COMPRESS_NONE: i32 = 0;
23const COMPRESS_NBIT: i32 = 1;
24const COMPRESS_SZIP: i32 = 2;
25const COMPRESS_ZLIB: i32 = 3;
26const COMPRESS_BLOSC: i32 = 4;
27const COMPRESS_BSHUF: i32 = 5;
28const COMPRESS_LZ4: i32 = 6;
29const COMPRESS_JPEG: i32 = 7;
30
31/// C ADCore BLOSC compressor sub-types.
32const BLOSC_LZ: i32 = 0;
33const BLOSC_LZ4: i32 = 1;
34const BLOSC_LZ4HC: i32 = 2;
35const BLOSC_SNAPPY: i32 = 3;
36const BLOSC_ZLIB: i32 = 4;
37const BLOSC_ZSTD: i32 = 5;
38
39/// Maximum number of extra dimensions (C `MAXEXTRADIMS`).
40const MAX_EXTRA_DIMS: usize = 10;
41
42/// Name of the HDF5 attribute that records the NDArray data type ordinal
43/// (matches C `NDDataType_t`). `read_file` uses it to recover the exact type.
44const DTYPE_ATTR: &str = "NDArrayDataType";
45
46/// User-controlled chunk geometry (C `HDF5_*Chunks` params).
47#[derive(Clone)]
48struct ChunkConfig {
49    /// `HDF5_chunkSizeAuto` — when true, ignore the explicit row/col/frame
50    /// values and let the writer pick (full-frame spatial, one frame deep).
51    auto: bool,
52    n_row_chunks: usize,
53    n_col_chunks: usize,
54    n_frames_chunks: usize,
55    /// `HDF5_NDAttributeChunk` — chunk depth for NDAttribute datasets.
56    ndattr_chunk: usize,
57}
58
59impl Default for ChunkConfig {
60    fn default() -> Self {
61        Self {
62            auto: true,
63            n_row_chunks: 0,
64            n_col_chunks: 0,
65            n_frames_chunks: 1,
66            ndattr_chunk: 16,
67        }
68    }
69}
70
71/// One extra-dimension entry (C `HDF5_extraDimSizeN` / `HDF5_extraDimNameN`).
72#[derive(Clone, Default)]
73struct ExtraDim {
74    size: usize,
75    name: String,
76}
77
78/// State for a single open attribute time-series dataset (one per NDAttribute).
79/// Mirrors C++ `NDFileHDF5AttributeDataset`: a 1-D extensible dataset holding
80/// one numeric (or string) value per frame.
81struct AttributeDataset {
82    name: String,
83    data_type: NDAttrDataType,
84    /// Raw little-endian bytes accumulated, one element per frame.
85    buffer: Vec<u8>,
86    frames: usize,
87}
88
89impl AttributeDataset {
90    fn new(name: String, data_type: NDAttrDataType) -> Self {
91        Self {
92            name,
93            data_type,
94            buffer: Vec::new(),
95            frames: 0,
96        }
97    }
98
99    /// Element byte width for this attribute's numeric type. Strings are
100    /// stored as a fixed-width field (matching C++ `MAX_ATTRIBUTE_STRING_SIZE`).
101    fn element_size(&self) -> usize {
102        match self.data_type {
103            NDAttrDataType::Int8 | NDAttrDataType::UInt8 => 1,
104            NDAttrDataType::Int16 | NDAttrDataType::UInt16 => 2,
105            NDAttrDataType::Int32 | NDAttrDataType::UInt32 | NDAttrDataType::Float32 => 4,
106            NDAttrDataType::Int64 | NDAttrDataType::UInt64 | NDAttrDataType::Float64 => 8,
107            NDAttrDataType::String => MAX_ATTRIBUTE_STRING_SIZE,
108        }
109    }
110
111    /// Append one frame's value, encoding it to the dataset's native type.
112    fn push(&mut self, value: &NDAttrValue) {
113        let es = self.element_size();
114        let mut bytes = vec![0u8; es];
115        match self.data_type {
116            NDAttrDataType::Int8 => bytes[0] = value.as_i64().unwrap_or(0) as i8 as u8,
117            NDAttrDataType::UInt8 => bytes[0] = value.as_i64().unwrap_or(0) as u8,
118            NDAttrDataType::Int16 => {
119                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as i16).to_le_bytes())
120            }
121            NDAttrDataType::UInt16 => {
122                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as u16).to_le_bytes())
123            }
124            NDAttrDataType::Int32 => {
125                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as i32).to_le_bytes())
126            }
127            NDAttrDataType::UInt32 => {
128                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as u32).to_le_bytes())
129            }
130            NDAttrDataType::Int64 => {
131                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0)).to_le_bytes())
132            }
133            NDAttrDataType::UInt64 => {
134                bytes.copy_from_slice(&(value.as_i64().unwrap_or(0) as u64).to_le_bytes())
135            }
136            NDAttrDataType::Float32 => {
137                bytes.copy_from_slice(&(value.as_f64().unwrap_or(0.0) as f32).to_le_bytes())
138            }
139            NDAttrDataType::Float64 => {
140                bytes.copy_from_slice(&(value.as_f64().unwrap_or(0.0)).to_le_bytes())
141            }
142            NDAttrDataType::String => {
143                let s = value.as_string();
144                let src = s.as_bytes();
145                let n = src.len().min(es - 1);
146                bytes[..n].copy_from_slice(&src[..n]);
147            }
148        }
149        self.buffer.extend_from_slice(&bytes);
150        self.frames += 1;
151    }
152}
153
154/// Fixed string field width for string-typed attribute datasets
155/// (C++ `MAX_ATTRIBUTE_STRING_SIZE`).
156const MAX_ATTRIBUTE_STRING_SIZE: usize = 256;
157
158/// Internal handle: either a standard H5File or a SWMR streaming writer.
159enum Hdf5Handle {
160    Standard {
161        file: H5File,
162        /// Primary image dataset handle, created lazily on the first frame.
163        /// Retained across frames so the leading dimension can be extended
164        /// (`H5File::dataset` cannot re-open a dataset in write mode).
165        primary: Option<rust_hdf5::H5Dataset>,
166    },
167    Swmr {
168        // Boxed: `SwmrFileWriter` is much larger than the `Standard` variant.
169        writer: Box<SwmrFileWriter>,
170        ds_index: usize,
171        /// True only when a compression type was requested but no filter
172        /// pipeline could be built for it; false when compression is applied.
173        compression_dropped: bool,
174    },
175}
176
177/// HDF5 file writer using the rust-hdf5 crate.
178pub struct Hdf5Writer {
179    current_path: Option<PathBuf>,
180    handle: Option<Hdf5Handle>,
181    frame_count: usize,
182    /// Standard-mode frame band: LE bytes of frames buffered until a
183    /// `nFramesChunks`-deep chunk band fills. With one frame per chunk this
184    /// holds at most one frame.
185    frame_band: Vec<Vec<u8>>,
186    dataset_name: String,
187    /// Cached data type of the open primary dataset.
188    open_data_type: Option<NDDataType>,
189    /// Cached spatial (per-frame) dimensions, fastest-varying last.
190    open_frame_dims: Option<Vec<usize>>,
191    /// `Some(total)` when the open dataset has a fixed extra-dim leading
192    /// layout (created at full size, no per-frame extend); `None` when the
193    /// leading frame axis is extended per write.
194    open_extra_extent: Option<usize>,
195    // compression
196    compression_type: i32,
197    z_compress_level: u32,
198    szip_num_pixels: u32,
199    nbit_precision: u32,
200    nbit_offset: u32,
201    jpeg_quality: u32,
202    blosc_shuffle_type: i32,
203    blosc_compressor: i32,
204    blosc_compress_level: u32,
205    // chunking & layout
206    chunk: ChunkConfig,
207    n_extra_dims: usize,
208    extra_dims: [ExtraDim; MAX_EXTRA_DIMS],
209    fill_value: f64,
210    dim_att_datasets: bool,
211    // SWMR
212    swmr_mode: bool,
213    flush_nth_frame: usize,
214    pub swmr_cb_counter: u32,
215    // options
216    pub store_attributes: bool,
217    pub store_performance: bool,
218    pub total_runtime: f64,
219    pub total_bytes: u64,
220    /// Per-frame I/O timing rows for the `timestamp` performance dataset.
221    /// Each row is the 5 doubles C++ `writePerformanceDataset` records.
222    perf_rows: Vec<[f64; 5]>,
223    perf_prev: Option<std::time::Instant>,
224    perf_first: Option<std::time::Instant>,
225    /// Open NDAttribute time-series datasets, keyed by attribute name.
226    attr_datasets: Vec<AttributeDataset>,
227    /// Layout XML state.
228    layout_filename: Option<PathBuf>,
229    layout: Option<Hdf5Layout>,
230    pub layout_valid: bool,
231    pub layout_error: String,
232    /// Full path of the primary image dataset for the currently-open file.
233    /// `"data"` (flat root) when no valid layout is loaded; the layout's
234    /// `det_default` dataset path (e.g. `entry/instrument/detector/data`)
235    /// otherwise. Leading slash stripped — keyed as `rust-hdf5` keys datasets.
236    resolved_dataset_path: String,
237    /// Group prefix (no leading/trailing slash) for NDAttribute datasets.
238    /// Empty when flat; the layout `ndattr_default` group otherwise.
239    resolved_ndattr_group: String,
240    /// Group prefix for the performance dataset. Empty when flat.
241    resolved_perf_group: String,
242}
243
244impl Hdf5Writer {
245    pub fn new() -> Self {
246        Self {
247            current_path: None,
248            handle: None,
249            frame_count: 0,
250            frame_band: Vec::new(),
251            dataset_name: "data".to_string(),
252            open_data_type: None,
253            open_frame_dims: None,
254            open_extra_extent: None,
255            compression_type: 0,
256            z_compress_level: 6,
257            szip_num_pixels: 16,
258            nbit_precision: 0,
259            nbit_offset: 0,
260            jpeg_quality: 90,
261            blosc_shuffle_type: 0,
262            blosc_compressor: 0,
263            blosc_compress_level: 5,
264            chunk: ChunkConfig::default(),
265            n_extra_dims: 0,
266            extra_dims: Default::default(),
267            fill_value: 0.0,
268            dim_att_datasets: false,
269            swmr_mode: false,
270            flush_nth_frame: 0,
271            swmr_cb_counter: 0,
272            store_attributes: true,
273            store_performance: false,
274            total_runtime: 0.0,
275            total_bytes: 0,
276            perf_rows: Vec::new(),
277            perf_prev: None,
278            perf_first: None,
279            attr_datasets: Vec::new(),
280            layout_filename: None,
281            layout: None,
282            layout_valid: false,
283            layout_error: String::new(),
284            resolved_dataset_path: "data".to_string(),
285            resolved_ndattr_group: String::new(),
286            resolved_perf_group: String::new(),
287        }
288    }
289
290    pub fn set_dataset_name(&mut self, name: &str) {
291        self.dataset_name = name.to_string();
292    }
293
294    pub fn set_compression_type(&mut self, v: i32) {
295        self.compression_type = v;
296    }
297
298    pub fn set_z_compress_level(&mut self, v: u32) {
299        self.z_compress_level = v;
300    }
301
302    pub fn set_szip_num_pixels(&mut self, v: u32) {
303        self.szip_num_pixels = v;
304    }
305
306    pub fn set_blosc_shuffle_type(&mut self, v: i32) {
307        self.blosc_shuffle_type = v;
308    }
309
310    pub fn set_blosc_compressor(&mut self, v: i32) {
311        self.blosc_compressor = v;
312    }
313
314    pub fn set_blosc_compress_level(&mut self, v: u32) {
315        self.blosc_compress_level = v;
316    }
317
318    pub fn set_nbit_precision(&mut self, v: u32) {
319        self.nbit_precision = v;
320    }
321
322    pub fn set_nbit_offset(&mut self, v: u32) {
323        self.nbit_offset = v;
324    }
325
326    pub fn set_jpeg_quality(&mut self, v: u32) {
327        self.jpeg_quality = v;
328    }
329
330    pub fn set_store_attributes(&mut self, v: bool) {
331        self.store_attributes = v;
332    }
333
334    pub fn set_store_performance(&mut self, v: bool) {
335        self.store_performance = v;
336    }
337
338    pub fn set_swmr_mode(&mut self, v: bool) {
339        self.swmr_mode = v;
340    }
341
342    pub fn set_flush_nth_frame(&mut self, v: usize) {
343        self.flush_nth_frame = v;
344    }
345
346    pub fn set_chunk_size_auto(&mut self, v: bool) {
347        self.chunk.auto = v;
348    }
349
350    pub fn set_n_row_chunks(&mut self, v: usize) {
351        self.chunk.n_row_chunks = v;
352    }
353
354    pub fn set_n_col_chunks(&mut self, v: usize) {
355        self.chunk.n_col_chunks = v;
356    }
357
358    pub fn set_n_frames_chunks(&mut self, v: usize) {
359        self.chunk.n_frames_chunks = v;
360    }
361
362    pub fn set_ndattr_chunk(&mut self, v: usize) {
363        self.chunk.ndattr_chunk = v.max(1);
364    }
365
366    pub fn set_n_extra_dims(&mut self, v: usize) {
367        self.n_extra_dims = v.min(MAX_EXTRA_DIMS);
368    }
369
370    pub fn set_extra_dim_size(&mut self, idx: usize, size: usize) {
371        if idx < MAX_EXTRA_DIMS {
372            self.extra_dims[idx].size = size;
373        }
374    }
375
376    pub fn set_extra_dim_name(&mut self, idx: usize, name: &str) {
377        if idx < MAX_EXTRA_DIMS {
378            self.extra_dims[idx].name = name.to_string();
379        }
380    }
381
382    pub fn set_fill_value(&mut self, v: f64) {
383        self.fill_value = v;
384    }
385
386    pub fn set_dim_att_datasets(&mut self, v: bool) {
387        self.dim_att_datasets = v;
388    }
389
390    /// Set the layout XML filename and (re)parse it. Returns whether parsing
391    /// succeeded; `layout_error` carries any message (C `HDF5_layoutErrorMsg`).
392    pub fn set_layout_filename(&mut self, path: &str) -> bool {
393        if path.trim().is_empty() {
394            self.layout_filename = None;
395            self.layout = None;
396            self.layout_valid = false;
397            self.layout_error.clear();
398            return true;
399        }
400        let p = PathBuf::from(path);
401        match Hdf5Layout::from_file(&p) {
402            Ok(layout) => {
403                self.layout_filename = Some(p);
404                self.layout = Some(layout);
405                self.layout_valid = true;
406                self.layout_error.clear();
407                true
408            }
409            Err(e) => {
410                self.layout_filename = Some(p);
411                self.layout = None;
412                self.layout_valid = false;
413                self.layout_error = e.0;
414                false
415            }
416        }
417    }
418
419    pub fn frame_count(&self) -> usize {
420        self.frame_count
421    }
422
423    /// Trigger a SWMR flush. No-op if not in SWMR mode.
424    pub fn flush_swmr(&mut self) {
425        if let Some(Hdf5Handle::Swmr { ref mut writer, .. }) = self.handle {
426            if writer.flush().is_ok() {
427                self.swmr_cb_counter += 1;
428            }
429        }
430    }
431
432    /// Returns true if SWMR is currently active.
433    pub fn is_swmr_active(&self) -> bool {
434        matches!(self.handle, Some(Hdf5Handle::Swmr { .. }))
435    }
436
437    /// Whether a requested SWMR compression type had no buildable pipeline.
438    pub fn swmr_compression_dropped(&self) -> bool {
439        matches!(
440            self.handle,
441            Some(Hdf5Handle::Swmr {
442                compression_dropped: true,
443                ..
444            })
445        )
446    }
447
448    /// Build a FilterPipeline from the current compression settings.
449    fn build_pipeline(&self, element_size: usize) -> Option<FilterPipeline> {
450        match self.compression_type {
451            COMPRESS_NONE => None,
452            COMPRESS_ZLIB => Some(FilterPipeline::deflate(self.z_compress_level)),
453            COMPRESS_SZIP => Some(FilterPipeline {
454                filters: vec![Filter {
455                    id: FILTER_SZIP,
456                    flags: 0,
457                    cd_values: vec![4, self.szip_num_pixels],
458                }],
459            }),
460            COMPRESS_LZ4 => Some(FilterPipeline::lz4()),
461            COMPRESS_BSHUF => Some(FilterPipeline {
462                // Bitshuffle (HDF5 filter 32008): cd_values are
463                // [major_ver, minor_ver, elem_size, block_size, comp_type].
464                // comp_type 2 == LZ4, matching ADCore's default bitshuffle.
465                filters: vec![Filter {
466                    id: FILTER_BSHUF,
467                    flags: 0,
468                    cd_values: vec![0, 0, element_size as u32, 0, 2],
469                }],
470            }),
471            COMPRESS_BLOSC => {
472                let compressor_code = match self.blosc_compressor {
473                    BLOSC_LZ => 0,
474                    BLOSC_LZ4 => 1,
475                    BLOSC_LZ4HC => 2,
476                    BLOSC_SNAPPY => 3,
477                    BLOSC_ZLIB => 4,
478                    BLOSC_ZSTD => 5,
479                    _ => 0,
480                };
481                Some(FilterPipeline {
482                    filters: vec![Filter {
483                        id: FILTER_BLOSC,
484                        flags: 0,
485                        cd_values: vec![
486                            2,
487                            2,
488                            element_size as u32,
489                            0,
490                            self.blosc_compress_level,
491                            self.blosc_shuffle_type as u32,
492                            compressor_code,
493                        ],
494                    }],
495                })
496            }
497            COMPRESS_NBIT => {
498                if self.nbit_precision > 0 {
499                    Some(FilterPipeline {
500                        filters: vec![Filter {
501                            id: FILTER_NBIT,
502                            flags: 0,
503                            cd_values: vec![self.nbit_precision, self.nbit_offset],
504                        }],
505                    })
506                } else {
507                    None
508                }
509            }
510            COMPRESS_JPEG => Some(FilterPipeline {
511                filters: vec![Filter {
512                    id: FILTER_JPEG,
513                    flags: 0,
514                    cd_values: vec![self.jpeg_quality],
515                }],
516            }),
517            _ => None,
518        }
519    }
520
521    /// Compute the dataset shape and chunk geometry for the primary image
522    /// dataset.
523    ///
524    /// Layout, fastest-varying last: `[frame, Y, X]`. The leading frame axis
525    /// is extensible. When `HDF5_nExtraDims = N` is set, the leading axis is
526    /// fixed at `product(extraDimSizeN..)` and the dataset is created at full
527    /// size up front; the extra-dimension sizes and names are recorded as
528    /// HDF5 attributes (`HDF5_nExtraDims`, `HDF5_extraDimSize0..`,
529    /// `HDF5_extraDimName0..`) so the N-dimensional layout is recoverable.
530    ///
531    /// The chunk shape is `[fc, rc, cc]` for a 2-D frame: `fc` frames per
532    /// chunk (`HDF5_nFramesChunks`), and `rc`/`cc` the row/column chunk sizes
533    /// (`HDF5_nRowChunks` / `HDF5_nColChunks`; 0, auto, or out-of-range → the
534    /// full dimension, matching C++ `NDFileHDF5` chunk-size selection). A
535    /// chunk band is written as a grid of `write_chunk_at` tiles; `close_file`
536    /// calls `set_extent` to trim the logical extent to the exact frame count
537    /// (`rust-hdf5` 0.2.15), so a non-dividing chunk size or a partial final
538    /// band never pads the frame shape. With a fixed extra-dim layout the
539    /// frame axis stays one frame per chunk (the extra dims own that axis).
540    ///
541    /// Returns `(shape, chunk, extra_dim_extent)` where `extra_dim_extent` is
542    /// `Some(total_frames)` when extra dims fix the dataset size up front, or
543    /// `None` when the leading frame axis is extended per write.
544    fn primary_layout(&self, frame_dims: &[usize]) -> (Vec<usize>, Vec<usize>, Option<usize>) {
545        let extra_extent = if self.n_extra_dims > 0 {
546            Some(
547                (0..self.n_extra_dims)
548                    .map(|i| self.extra_dims[i].size.max(1))
549                    .product::<usize>(),
550            )
551        } else {
552            None
553        };
554
555        let mut shape: Vec<usize> = Vec::new();
556        // Leading frame axis: full extra-dim product, or 1 (extensible).
557        shape.push(extra_extent.unwrap_or(1));
558        shape.extend_from_slice(frame_dims);
559
560        let ndims = shape.len();
561        let mut chunk = vec![1usize; ndims];
562        // Frames per chunk: the extra-dim layout owns the leading axis, so it
563        // stays one frame per chunk; otherwise honor HDF5_nFramesChunks.
564        chunk[0] = if extra_extent.is_some() {
565            1
566        } else {
567            self.chunk.n_frames_chunks.max(1)
568        };
569        if frame_dims.len() == 2 {
570            // 2-D frame: honor the user row/column chunk sizes.
571            let y = frame_dims[0].max(1);
572            let x = frame_dims[1].max(1);
573            chunk[1] = Self::clamp_chunk(self.chunk.n_row_chunks, y, self.chunk.auto);
574            chunk[2] = Self::clamp_chunk(self.chunk.n_col_chunks, x, self.chunk.auto);
575        } else {
576            // Other rank: one full per-frame tile (no sub-tiling).
577            for (i, &d) in frame_dims.iter().enumerate() {
578                chunk[1 + i] = d.max(1);
579            }
580        }
581        (shape, chunk, extra_extent)
582    }
583
584    /// C++ `NDFileHDF5` chunk-size rule: 0, auto, or a value larger than the
585    /// dimension means "chunk the whole dimension"; otherwise the user value.
586    fn clamp_chunk(requested: usize, dim: usize, auto: bool) -> usize {
587        if auto || requested == 0 || requested > dim {
588            dim
589        } else {
590            requested
591        }
592    }
593
594    /// Write one chunk band (`chunk[0]` consecutive frames) into the primary
595    /// dataset at band index `band_idx`.
596    ///
597    /// The band is split into `ceil(Y/rc) x ceil(X/cc)` chunk tiles, each
598    /// written with `write_chunk_at(&[band_idx, row_tile, col_tile], ..)`.
599    /// Tiles are `[fc, rc, cc]`; edge tiles and a partial final band (fewer
600    /// than `fc` frames) are zero-padded. `close_file`'s `set_extent` trims
601    /// the resulting over-extension back to the exact frame count.
602    fn flush_band(
603        ds: &rust_hdf5::H5Dataset,
604        band_idx: usize,
605        frames: &[Vec<u8>],
606        frame_dims: &[usize],
607        chunk: &[usize],
608        elem_size: usize,
609    ) -> ADResult<()> {
610        let fc = chunk[0];
611        // Non-2-D frame: one chunk per band, frames stacked along the band
612        // axis (a partial band leaves trailing frames zero).
613        if frame_dims.len() != 2 {
614            let frame_len = frame_dims.iter().product::<usize>() * elem_size;
615            let mut buf = vec![0u8; fc * frame_len];
616            for (f, fb) in frames.iter().take(fc).enumerate() {
617                buf[f * frame_len..f * frame_len + frame_len].copy_from_slice(fb);
618            }
619            let mut coords = vec![0usize; chunk.len()];
620            coords[0] = band_idx;
621            return ds.write_chunk_at(&coords, &buf).map_err(|e| {
622                ADError::UnsupportedConversion(format!("HDF5 write_chunk_at error: {}", e))
623            });
624        }
625
626        let (y, x) = (frame_dims[0], frame_dims[1]);
627        let (rc, cc) = (chunk[1], chunk[2]);
628        let row_tiles = y.div_ceil(rc);
629        let col_tiles = x.div_ceil(cc);
630        for ry in 0..row_tiles {
631            for cx in 0..col_tiles {
632                let mut tile = vec![0u8; fc * rc * cc * elem_size];
633                for f in 0..fc {
634                    let Some(fb) = frames.get(f) else {
635                        break; // partial band: trailing frames stay zero
636                    };
637                    for r in 0..rc {
638                        let sy = ry * rc + r;
639                        if sy >= y {
640                            break;
641                        }
642                        for c in 0..cc {
643                            let sx = cx * cc + c;
644                            if sx >= x {
645                                break;
646                            }
647                            let src = (sy * x + sx) * elem_size;
648                            let dst = ((f * rc + r) * cc + c) * elem_size;
649                            tile[dst..dst + elem_size].copy_from_slice(&fb[src..src + elem_size]);
650                        }
651                    }
652                }
653                ds.write_chunk_at(&[band_idx, ry, cx], &tile).map_err(|e| {
654                    ADError::UnsupportedConversion(format!("HDF5 write_chunk_at error: {}", e))
655                })?;
656            }
657        }
658        Ok(())
659    }
660
661    /// Flush any partial frame band and trim the primary dataset's logical
662    /// extent to the exact frame count. Called from `close_file`.
663    fn finalize_standard_primary(&mut self) -> ADResult<()> {
664        let Some(frame_dims) = self.open_frame_dims.clone() else {
665            return Ok(());
666        };
667        let (_, chunk, extra_extent) = self.primary_layout(&frame_dims);
668        let elem_size = self.open_data_type.map(|t| t.element_size()).unwrap_or(1);
669        let total = self.frame_count;
670        let fc = chunk[0];
671        {
672            let ds = match &self.handle {
673                Some(Hdf5Handle::Standard {
674                    primary: Some(ds), ..
675                }) => ds,
676                _ => return Ok(()),
677            };
678            if !self.frame_band.is_empty() {
679                let band_idx = total.saturating_sub(1) / fc;
680                Self::flush_band(
681                    ds,
682                    band_idx,
683                    &self.frame_band,
684                    &frame_dims,
685                    &chunk,
686                    elem_size,
687                )?;
688            }
689            // Trim the logical extent: write_chunk_at rounds dims up to chunk
690            // boundaries; set_extent restores the exact [N, Y, X] (extensible
691            // datasets only — a fixed extra-dim dataset has no over-extension).
692            if extra_extent.is_none() && total > 0 {
693                let mut dims = vec![total];
694                dims.extend_from_slice(&frame_dims);
695                ds.set_extent(&dims).map_err(|e| {
696                    ADError::UnsupportedConversion(format!("HDF5 set_extent error: {}", e))
697                })?;
698            }
699        }
700        self.frame_band.clear();
701        Ok(())
702    }
703
704    /// Open file in SWMR streaming mode.
705    ///
706    /// Ordering mirrors C `NDFileHDF5::openFile` (`NDFileHDF5.cpp:264`-`335`):
707    /// the file layout tree and datasets are created, then `createHardLinks`
708    /// (`NDFileHDF5.cpp:320`-`321`) runs, and only then `startSWMR`
709    /// (`NDFileHDF5.cpp:324`-`326`). The new rust-hdf5 0.2.17 `SwmrFileWriter`
710    /// exposes `create_group` / `assign_dataset_to_group` / `create_hard_link`
711    /// callable before `start_swmr()`; a group or link created before
712    /// `start_swmr()` is visible to SWMR readers for the whole streaming
713    /// window. So here the image dataset is placed at the layout's nested
714    /// `resolved_dataset_path` and the layout `<hardlink>` elements are
715    /// materialised before SWMR mode is entered — not on the close path.
716    fn open_swmr(&mut self, path: &Path, array: &NDArray) -> ADResult<()> {
717        let mut swmr = SwmrFileWriter::create(path)
718            .map_err(|e| ADError::UnsupportedConversion(format!("SWMR create error: {}", e)))?;
719
720        let frame_dims: Vec<u64> = array.dims.iter().rev().map(|d| d.size as u64).collect();
721
722        // Full chunk geometry, `[fc, rc, cc]`: HDF5_nFramesChunks deep and
723        // the row/column tile sizes. rust-hdf5 0.2.15
724        // `create_streaming_dataset_chunked` band-buffers whole frames and
725        // zero-pads the final partial band at close, keeping the logical
726        // frame count exact.
727        let element_size = array.data.data_type().element_size();
728        let pipeline = self.build_pipeline(element_size);
729        let chunk: Vec<u64> = {
730            let usize_dims: Vec<usize> = array.dims.iter().rev().map(|d| d.size).collect();
731            let (_, c, _) = self.primary_layout(&usize_dims);
732            c.iter().map(|&v| v as u64).collect()
733        };
734
735        // The streaming dataset is created with its full nested layout path
736        // as the dataset name (default flat `data` without a layout). The
737        // `SwmrFileWriter` emits a path-named dataset that is also assigned to
738        // a group under that group with just the leaf, while keeping the full
739        // name addressable so a layout `<hardlink target="/entry/.../data">`
740        // resolves against it. `ds_group_path` is the parent group the
741        // dataset is re-parented into via `assign_dataset_to_group` below.
742        let ds_group_path: Option<String> = self
743            .resolved_dataset_path
744            .rsplit_once('/')
745            .map(|(group_path, _leaf)| group_path.to_string());
746        let ds_name = self.resolved_dataset_path.clone();
747
748        macro_rules! create_ds {
749            ($t:ty) => {
750                match pipeline.clone() {
751                    Some(pl) => swmr
752                        .create_streaming_dataset_chunked_compressed::<$t>(
753                            &ds_name,
754                            &frame_dims,
755                            &chunk,
756                            pl,
757                        )
758                        .map_err(|e| {
759                            ADError::UnsupportedConversion(format!(
760                                "SWMR create compressed dataset error: {}",
761                                e
762                            ))
763                        }),
764                    None => swmr
765                        .create_streaming_dataset_chunked::<$t>(&ds_name, &frame_dims, &chunk)
766                        .map_err(|e| {
767                            ADError::UnsupportedConversion(format!(
768                                "SWMR create dataset error: {}",
769                                e
770                            ))
771                        }),
772                }
773            };
774        }
775
776        let ds_index = match array.data.data_type() {
777            NDDataType::Int8 => create_ds!(i8)?,
778            NDDataType::UInt8 => create_ds!(u8)?,
779            NDDataType::Int16 => create_ds!(i16)?,
780            NDDataType::UInt16 => create_ds!(u16)?,
781            NDDataType::Int32 => create_ds!(i32)?,
782            NDDataType::UInt32 => create_ds!(u32)?,
783            NDDataType::Int64 => create_ds!(i64)?,
784            NDDataType::UInt64 => create_ds!(u64)?,
785            NDDataType::Float32 => create_ds!(f32)?,
786            NDDataType::Float64 => create_ds!(f64)?,
787        };
788
789        // Build the layout group tree, place the image dataset inside its
790        // nested layout group, materialise its constant attributes and the
791        // layout `<hardlink>` elements — all BEFORE `start_swmr()` so SWMR
792        // readers see the nested paths and aliases for the whole streaming
793        // window. C `NDFileHDF5.cpp:320`-`326`: `createHardLinks` then
794        // `startSWMR`.
795        self.build_swmr_layout_groups(&mut swmr)?;
796        if let Some(ref group_path) = ds_group_path {
797            // `SwmrFileWriter` keys groups by their absolute path (leading
798            // `/`); `resolved_dataset_path` is stored stripped, so re-add it.
799            let abs_group = format!("/{}", group_path);
800            swmr.assign_dataset_to_group(&abs_group, ds_index)
801                .map_err(|e| {
802                    ADError::UnsupportedConversion(format!(
803                        "SWMR assign dataset to group '{}': {}",
804                        abs_group, e
805                    ))
806                })?;
807        }
808        self.write_swmr_layout_dataset_attrs(&mut swmr, ds_index)?;
809        self.build_swmr_layout_hardlinks(&mut swmr)?;
810
811        swmr.start_swmr()
812            .map_err(|e| ADError::UnsupportedConversion(format!("SWMR start error: {}", e)))?;
813
814        // Compression is applied to SWMR datasets via the filter pipeline
815        // above. `compression_dropped` is only set when a compression type was
816        // requested but no pipeline could be built for it (an unsupported
817        // compressor) — never a silent drop.
818        let compression_dropped = self.compression_type != COMPRESS_NONE && pipeline.is_none();
819        if compression_dropped {
820            eprintln!(
821                "NDFileHDF5: WARNING — SWMR mode requested compression type {} \
822                 but no filter pipeline could be built for it; the SWMR file \
823                 will be written UNCOMPRESSED.",
824                self.compression_type
825            );
826        }
827
828        self.handle = Some(Hdf5Handle::Swmr {
829            writer: Box::new(swmr),
830            ds_index,
831            compression_dropped,
832        });
833        self.open_data_type = Some(array.data.data_type());
834        self.open_frame_dims = Some(array.dims.iter().rev().map(|d| d.size).collect::<Vec<_>>());
835        Ok(())
836    }
837
838    /// Build every group node declared in the loaded layout XML against a
839    /// `SwmrFileWriter`, the SWMR counterpart of [`build_layout_groups`].
840    ///
841    /// Paths are created parent-first (shortest path-depth first) via the
842    /// rust-hdf5 0.2.17 `SwmrFileWriter::create_group` API, which takes the
843    /// parent group path and a leaf name. Called from `open_swmr` before
844    /// `start_swmr()` so the groups are visible to SWMR readers for the whole
845    /// streaming window. No-op when no layout is loaded.
846    fn build_swmr_layout_groups(&self, swmr: &mut SwmrFileWriter) -> ADResult<()> {
847        let layout = match self.layout.as_ref() {
848            Some(l) => l,
849            None => return Ok(()),
850        };
851        fn collect(g: &crate::hdf5_layout::LayoutGroup, prefix: &str, out: &mut Vec<String>) {
852            let here = if prefix.is_empty() {
853                g.name.clone()
854            } else {
855                format!("{}/{}", prefix, g.name)
856            };
857            out.push(here.clone());
858            for sub in &g.groups {
859                collect(sub, &here, out);
860            }
861        }
862        let mut paths = Vec::new();
863        for g in &layout.groups {
864            collect(g, "", &mut paths);
865        }
866        paths.sort_by_key(|p| p.matches('/').count());
867        paths.dedup();
868        let mut created: std::collections::HashSet<String> = std::collections::HashSet::new();
869        for path in &paths {
870            if created.contains(path) {
871                continue;
872            }
873            let (parent, leaf) = match path.rsplit_once('/') {
874                Some((p, l)) => (format!("/{}", p), l),
875                None => ("/".to_string(), path.as_str()),
876            };
877            swmr.create_group(&parent, leaf).map_err(|e| {
878                ADError::UnsupportedConversion(format!("SWMR layout group '{}': {}", path, e))
879            })?;
880            created.insert(path.clone());
881        }
882        Ok(())
883    }
884
885    /// Materialise every `<hardlink>` declared in the loaded layout XML against
886    /// a `SwmrFileWriter`, the SWMR counterpart of [`build_layout_hardlinks`].
887    ///
888    /// Uses the rust-hdf5 0.2.17 `SwmrFileWriter::create_hard_link` API. Called
889    /// from `open_swmr` after the layout groups and image dataset exist and
890    /// before `start_swmr()` — matching C `NDFileHDF5.cpp:320`-`321`
891    /// `createHardLinks`, which runs before `startSWMR`. A link created before
892    /// `start_swmr()` is visible to SWMR readers for the whole streaming
893    /// window. No-op when no layout is loaded.
894    fn build_swmr_layout_hardlinks(&self, swmr: &mut SwmrFileWriter) -> ADResult<()> {
895        let layout = match self.layout.as_ref() {
896            Some(l) => l,
897            None => return Ok(()),
898        };
899        fn collect<'a>(
900            g: &'a crate::hdf5_layout::LayoutGroup,
901            prefix: &str,
902            out: &mut Vec<(String, &'a crate::hdf5_layout::LayoutHardlink)>,
903        ) {
904            let here = if prefix.is_empty() {
905                g.name.clone()
906            } else {
907                format!("{}/{}", prefix, g.name)
908            };
909            for hl in &g.hardlinks {
910                out.push((here.clone(), hl));
911            }
912            for sub in &g.groups {
913                collect(sub, &here, out);
914            }
915        }
916        let mut links = Vec::new();
917        for g in &layout.groups {
918            collect(g, "", &mut links);
919        }
920        for (parent_path, hl) in &links {
921            let parent = format!("/{}", parent_path);
922            swmr.create_hard_link(&parent, &hl.name, &hl.target)
923                .map_err(|e| {
924                    ADError::UnsupportedConversion(format!(
925                        "SWMR layout hardlink '{}/{}' -> '{}': {}",
926                        parent_path, hl.name, hl.target, e
927                    ))
928                })?;
929        }
930        Ok(())
931    }
932
933    /// Materialise the loaded layout XML's `constant` HDF5 attributes attached
934    /// to the primary image dataset against a `SwmrFileWriter`. This mirrors
935    /// the standard close path's `layout_ds_attrs` block in
936    /// `create_primary_dataset` (e.g. the NeXus `signal=1` marker). Only
937    /// `constant`-sourced attributes are materialised; `ndattribute`-sourced
938    /// nodes carry per-frame values and are out of scope here. No-op when no
939    /// layout is loaded.
940    fn write_swmr_layout_dataset_attrs(
941        &self,
942        swmr: &mut SwmrFileWriter,
943        ds_index: usize,
944    ) -> ADResult<()> {
945        use crate::hdf5_layout::{LayoutDataType, LayoutSource};
946        let layout = match self.layout.as_ref() {
947            Some(l) => l,
948            None => return Ok(()),
949        };
950        let resolved_ds = self.resolved_dataset_path.as_str();
951        let mut attrs: Vec<(String, LayoutDataType, String)> = Vec::new();
952        layout.for_each_dataset(|path, d| {
953            let full = format!("{}/{}", path, d.name);
954            if full.trim_start_matches('/') == resolved_ds {
955                for a in &d.attributes {
956                    if a.source == LayoutSource::Constant {
957                        attrs.push((a.name.clone(), a.data_type, a.value.clone()));
958                    }
959                }
960            }
961        });
962        for (name, dtype, value) in &attrs {
963            match dtype {
964                LayoutDataType::Int => {
965                    let v: i64 = value.trim().parse().unwrap_or(0);
966                    swmr.set_dataset_attr_numeric(ds_index, name, &v)
967                }
968                LayoutDataType::Float => {
969                    let v: f64 = value.trim().parse().unwrap_or(0.0);
970                    swmr.set_dataset_attr_numeric(ds_index, name, &v)
971                }
972                LayoutDataType::String => swmr.set_dataset_attr_string(ds_index, name, value),
973            }
974            .map_err(|e| {
975                ADError::UnsupportedConversion(format!(
976                    "SWMR layout dataset attribute '{}': {}",
977                    name, e
978                ))
979            })?;
980        }
981        Ok(())
982    }
983
984    /// Resolve the on-disk dataset/group paths from the loaded layout XML.
985    ///
986    /// With a valid layout this places the image dataset at the layout's
987    /// `det_default` dataset path, NDAttribute datasets under the
988    /// `ndattr_default` group, and the performance dataset under the group
989    /// holding the `timestamp` dataset — matching C `NDFileHDF5`'s
990    /// `/entry/instrument/detector/data` tree. Without a layout the flat
991    /// root defaults (`data`, `NDAttributes`, `performance`) are kept.
992    ///
993    /// All returned paths have the leading `/` stripped, since `rust-hdf5`
994    /// keys datasets/groups without a leading slash.
995    fn resolve_layout_paths(&mut self) {
996        let strip = |s: String| s.trim_start_matches('/').to_string();
997        match self.layout.as_ref() {
998            Some(layout) => {
999                self.resolved_dataset_path = layout
1000                    .detector_dataset_path()
1001                    .map(strip)
1002                    .unwrap_or_else(|| self.dataset_name.clone());
1003                self.resolved_ndattr_group =
1004                    layout.ndattr_default_group().map(strip).unwrap_or_default();
1005                self.resolved_perf_group = layout
1006                    .dataset_group_path("timestamp")
1007                    .map(strip)
1008                    .unwrap_or_default();
1009            }
1010            None => {
1011                self.resolved_dataset_path = self.dataset_name.clone();
1012                self.resolved_ndattr_group.clear();
1013                self.resolved_perf_group.clear();
1014            }
1015        }
1016    }
1017
1018    /// Build every group node declared in the loaded layout XML so that empty
1019    /// NeXus-style groups (e.g. an `NXdata` placeholder) also exist on disk,
1020    /// not just the groups implied by the dataset placement. No-op when no
1021    /// layout is loaded.
1022    ///
1023    /// `rust-hdf5` 0.2.15's `create_group` errors on a duplicate path, so each
1024    /// distinct group path is created exactly once via a created-set; paths
1025    /// are processed shortest-first so a parent always exists before a child.
1026    fn build_layout_groups(&self, file: &H5File) -> ADResult<()> {
1027        let layout = match self.layout.as_ref() {
1028            Some(l) => l,
1029            None => return Ok(()),
1030        };
1031        fn collect(g: &crate::hdf5_layout::LayoutGroup, prefix: &str, out: &mut Vec<String>) {
1032            let here = if prefix.is_empty() {
1033                g.name.clone()
1034            } else {
1035                format!("{}/{}", prefix, g.name)
1036            };
1037            out.push(here.clone());
1038            for sub in &g.groups {
1039                collect(sub, &here, out);
1040            }
1041        }
1042        let mut paths = Vec::new();
1043        for g in &layout.groups {
1044            collect(g, "", &mut paths);
1045        }
1046        paths.sort_by_key(|p| p.matches('/').count());
1047        paths.dedup();
1048        let mut created: std::collections::HashSet<String> = std::collections::HashSet::new();
1049        for path in &paths {
1050            if created.contains(path) {
1051                continue;
1052            }
1053            let (parent, leaf) = match path.rsplit_once('/') {
1054                Some((p, l)) => (p, l),
1055                None => ("", path.as_str()),
1056            };
1057            // The parent path was created earlier (shorter, sorted first).
1058            let parent_group = if parent.is_empty() {
1059                None
1060            } else {
1061                Some(Self::open_write_group(file, parent)?)
1062            };
1063            match parent_group.as_ref() {
1064                Some(g) => g.create_group(leaf),
1065                None => file.create_group(leaf),
1066            }
1067            .map_err(|e| {
1068                ADError::UnsupportedConversion(format!("HDF5 layout group '{}': {}", path, e))
1069            })?;
1070            created.insert(path.clone());
1071        }
1072        Ok(())
1073    }
1074
1075    /// Materialise every `<hardlink>` declared in the loaded layout XML.
1076    ///
1077    /// A layout `<hardlink name="..." target="..."/>` inside a `<group>`
1078    /// declares an HDF5 hard link: an additional name (`name`, a leaf within
1079    /// the enclosing group) for the object already living at `target` (an
1080    /// absolute object path). C++ `NDFileHDF5::createHardLinks` walks the
1081    /// layout after the groups/datasets exist and calls `H5Lcreate_hard`.
1082    ///
1083    /// Called from `close_file` for the standard (non-SWMR) close path so that
1084    /// both the primary image dataset and the per-frame NDAttribute datasets —
1085    /// any of which a hardlink may target — already exist on disk. No-op when
1086    /// no layout is loaded.
1087    ///
1088    /// `file` is the live `Standard` write-mode HDF5 handle. The SWMR path has
1089    /// its own counterpart, [`build_swmr_layout_hardlinks`], which runs before
1090    /// `start_swmr()` (C++ `NDFileHDF5.cpp:320`-`326`: `createHardLinks` then
1091    /// `startSWMR`) so SWMR readers see the links during streaming.
1092    fn build_layout_hardlinks(&self, file: &H5File) -> ADResult<()> {
1093        let layout = match self.layout.as_ref() {
1094            Some(l) => l,
1095            None => return Ok(()),
1096        };
1097        // Collect (parent_group_path, hardlink) for every group in the tree.
1098        fn collect<'a>(
1099            g: &'a crate::hdf5_layout::LayoutGroup,
1100            prefix: &str,
1101            out: &mut Vec<(String, &'a crate::hdf5_layout::LayoutHardlink)>,
1102        ) {
1103            let here = if prefix.is_empty() {
1104                g.name.clone()
1105            } else {
1106                format!("{}/{}", prefix, g.name)
1107            };
1108            for hl in &g.hardlinks {
1109                out.push((here.clone(), hl));
1110            }
1111            for sub in &g.groups {
1112                collect(sub, &here, out);
1113            }
1114        }
1115        let mut links = Vec::new();
1116        for g in &layout.groups {
1117            collect(g, "", &mut links);
1118        }
1119        for (parent_path, hl) in &links {
1120            // The enclosing group already exists (created by
1121            // `build_layout_groups`); re-open it and create the link inside it.
1122            let parent = Self::open_write_group(file, parent_path)?;
1123            parent.link(&hl.name, &hl.target).map_err(|e| {
1124                ADError::UnsupportedConversion(format!(
1125                    "HDF5 layout hardlink '{}/{}' -> '{}': {}",
1126                    parent_path, hl.name, hl.target, e
1127                ))
1128            })?;
1129        }
1130        Ok(())
1131    }
1132
1133    /// Re-open an already-created group by full path in write mode. In write
1134    /// mode `H5Group::group` returns a handle without verification, so this is
1135    /// a pure handle constructor walking each path segment.
1136    fn open_write_group(file: &H5File, path: &str) -> ADResult<rust_hdf5::H5Group> {
1137        let mut current: Option<rust_hdf5::H5Group> = None;
1138        for seg in path.split('/').filter(|s| !s.is_empty()) {
1139            let next = match current.as_ref() {
1140                Some(g) => g.group(seg),
1141                None => file.root_group().group(seg),
1142            }
1143            .map_err(|e| {
1144                ADError::UnsupportedConversion(format!("HDF5 group reopen '{}': {}", seg, e))
1145            })?;
1146            current = Some(next);
1147        }
1148        current.ok_or_else(|| ADError::UnsupportedConversion("empty group path".into()))
1149    }
1150
1151    /// Create the primary image dataset on first frame in standard mode.
1152    /// The dataset is a single extensible `[nframes, .., Y, X]` array; later
1153    /// frames extend the leading dimension (C++ `NDFileHDF5Dataset`).
1154    fn create_primary_dataset(&mut self, array: &NDArray) -> ADResult<()> {
1155        let frame_dims: Vec<usize> = array.dims.iter().rev().map(|d| d.size).collect();
1156        let (shape, chunk, extra_extent) = self.primary_layout(&frame_dims);
1157        let element_size = array.data.data_type().element_size();
1158        let pipeline = self.build_pipeline(element_size);
1159        // Max shape: with extra dims the dataset is created at full size, so
1160        // every axis is fixed (`Some`). Without extra dims the leading frame
1161        // axis is extensible (`None`); spatial axes get headroom to the
1162        // chunk-aligned ceiling so a `write_chunk_at` edge tile of a
1163        // non-dividing chunk can extend into it — `close_file`'s `set_extent`
1164        // trims back to the exact frame shape.
1165        let max_shape: Vec<Option<usize>> = shape
1166            .iter()
1167            .zip(chunk.iter())
1168            .enumerate()
1169            .map(|(i, (&s, &c))| {
1170                if i == 0 {
1171                    if extra_extent.is_none() {
1172                        None
1173                    } else {
1174                        Some(s)
1175                    }
1176                } else if extra_extent.is_none() {
1177                    Some(s.div_ceil(c) * c)
1178                } else {
1179                    Some(s)
1180                }
1181            })
1182            .collect();
1183
1184        // Build the layout group hierarchy (if a layout XML is loaded) before
1185        // placing the dataset. With no layout this is a no-op and the dataset
1186        // lands flat at the file root.
1187        match self.handle {
1188            Some(Hdf5Handle::Standard { ref file, .. }) => self.build_layout_groups(file)?,
1189            _ => return Err(ADError::UnsupportedConversion("no HDF5 file open".into())),
1190        }
1191
1192        // Collect the `constant` HDF5 attributes the layout XML attaches to the
1193        // primary image dataset (the one at `resolved_dataset_path`, e.g. the
1194        // NeXus `signal=1` marker). Only constant attributes are materialised
1195        // here; `ndattribute`-sourced attribute nodes carry per-frame values
1196        // and are out of scope for the static dataset-creation path.
1197        let resolved_ds = self.resolved_dataset_path.clone();
1198        let layout_ds_attrs: Vec<(String, crate::hdf5_layout::LayoutDataType, String)> = self
1199            .layout
1200            .as_ref()
1201            .map(|l| {
1202                use crate::hdf5_layout::LayoutSource;
1203                let mut out = Vec::new();
1204                l.for_each_dataset(|path, d| {
1205                    let full = format!("{}/{}", path, d.name);
1206                    if full.trim_start_matches('/') == resolved_ds {
1207                        for a in &d.attributes {
1208                            if a.source == LayoutSource::Constant {
1209                                out.push((a.name.clone(), a.data_type, a.value.clone()));
1210                            }
1211                        }
1212                    }
1213                });
1214                out
1215            })
1216            .unwrap_or_default();
1217
1218        let h5file = match self.handle {
1219            Some(Hdf5Handle::Standard { ref file, .. }) => file,
1220            _ => return Err(ADError::UnsupportedConversion("no HDF5 file open".into())),
1221        };
1222
1223        // Resolve the dataset's parent group and leaf name. `resolved_dataset_path`
1224        // is e.g. `entry/instrument/detector/data` with a layout, or `data` flat.
1225        let (ds_group, ds_name): (Option<rust_hdf5::H5Group>, String) =
1226            match self.resolved_dataset_path.rsplit_once('/') {
1227                Some((group_path, leaf)) => (
1228                    Some(Self::open_write_group(h5file, group_path)?),
1229                    leaf.to_string(),
1230                ),
1231                None => (None, self.resolved_dataset_path.clone()),
1232            };
1233
1234        let dtype_ordinal = array.data.data_type() as i32;
1235        let fill = self.fill_value;
1236        let row_chunks = self.chunk.n_row_chunks as i32;
1237        let col_chunks = self.chunk.n_col_chunks as i32;
1238        let frame_chunks = self.chunk.n_frames_chunks as i32;
1239        let n_extra = self.n_extra_dims as i32;
1240        let extra_meta: Vec<(usize, i32, String)> = (0..self.n_extra_dims)
1241            .map(|i| {
1242                (
1243                    i,
1244                    self.extra_dims[i].size.max(1) as i32,
1245                    self.extra_dims[i].name.clone(),
1246                )
1247            })
1248            .collect();
1249
1250        macro_rules! create_ds {
1251            ($t:ty) => {{
1252                let mut builder = match ds_group.as_ref() {
1253                    Some(g) => g.new_dataset::<$t>(),
1254                    None => h5file.new_dataset::<$t>(),
1255                }
1256                .shape(&shape[..])
1257                .chunk(&chunk[..])
1258                .max_shape(&max_shape[..])
1259                // C parity: NDFileHDF5 sets HDF5_fillValue on the dataset
1260                // creation property list (H5Pset_fill_value). rust-hdf5 0.2.15
1261                // exposes `DatasetBuilder::fill_value`, which writes it into the
1262                // DCPL fill-value message so unwritten chunks read back as
1263                // `fill` rather than zero.
1264                .fill_value(fill as $t);
1265                if let Some(ref pl) = pipeline {
1266                    builder = builder.filter_pipeline(pl.clone());
1267                }
1268                let ds = builder.create(ds_name.as_str()).map_err(|e| {
1269                    ADError::UnsupportedConversion(format!("HDF5 dataset error: {}", e))
1270                })?;
1271                // Record the exact NDArray data type for lossless read-back.
1272                let _ = ds
1273                    .new_attr::<i32>()
1274                    .shape(())
1275                    .create(DTYPE_ATTR)
1276                    .and_then(|a| a.write_numeric(&dtype_ordinal));
1277                // Also expose the fill value as an attribute for tooling that
1278                // inspects HDF5_fillValue directly (the DCPL above is the
1279                // authoritative copy).
1280                let _ = ds
1281                    .new_attr::<f64>()
1282                    .shape(())
1283                    .create("HDF5_fillValue")
1284                    .and_then(|a| a.write_numeric(&fill));
1285                // Record the requested chunk geometry. The on-disk chunk is
1286                // one frame per chunk (crate limitation); these attributes
1287                // preserve the user's intent for downstream tooling.
1288                for (name, val) in [
1289                    ("HDF5_nRowChunks", row_chunks),
1290                    ("HDF5_nColChunks", col_chunks),
1291                    ("HDF5_nFramesChunks", frame_chunks),
1292                    ("HDF5_nExtraDims", n_extra),
1293                ] {
1294                    let _ = ds
1295                        .new_attr::<i32>()
1296                        .shape(())
1297                        .create(name)
1298                        .and_then(|a| a.write_numeric(&val));
1299                }
1300                // Record extra-dimension sizes and names so the flat leading
1301                // axis can be reshaped into the intended N-D layout.
1302                for (i, size, name) in &extra_meta {
1303                    let _ = ds
1304                        .new_attr::<i32>()
1305                        .shape(())
1306                        .create(&format!("HDF5_extraDimSize{}", i))
1307                        .and_then(|a| a.write_numeric(size));
1308                    if !name.is_empty() {
1309                        let s = rust_hdf5::types::VarLenUnicode(name.clone());
1310                        let _ = ds
1311                            .new_attr::<rust_hdf5::types::VarLenUnicode>()
1312                            .shape(())
1313                            .create(&format!("HDF5_extraDimName{}", i))
1314                            .and_then(|a| a.write_scalar(&s));
1315                    }
1316                }
1317                // Materialise the layout XML's constant dataset attributes
1318                // (e.g. NeXus `signal=1`), typed per the XML `type` attribute.
1319                for (aname, atype, avalue) in &layout_ds_attrs {
1320                    use crate::hdf5_layout::LayoutDataType;
1321                    match atype {
1322                        LayoutDataType::Int => {
1323                            let v: i64 = avalue.trim().parse().unwrap_or(0);
1324                            let _ = ds
1325                                .new_attr::<i64>()
1326                                .shape(())
1327                                .create(aname)
1328                                .and_then(|a| a.write_numeric(&v));
1329                        }
1330                        LayoutDataType::Float => {
1331                            let v: f64 = avalue.trim().parse().unwrap_or(0.0);
1332                            let _ = ds
1333                                .new_attr::<f64>()
1334                                .shape(())
1335                                .create(aname)
1336                                .and_then(|a| a.write_numeric(&v));
1337                        }
1338                        LayoutDataType::String => {
1339                            let s = rust_hdf5::types::VarLenUnicode(avalue.clone());
1340                            let _ = ds
1341                                .new_attr::<rust_hdf5::types::VarLenUnicode>()
1342                                .shape(())
1343                                .create(aname)
1344                                .and_then(|a| a.write_scalar(&s));
1345                        }
1346                    }
1347                }
1348                ds
1349            }};
1350        }
1351
1352        let ds = match array.data {
1353            NDDataBuffer::I8(_) => create_ds!(i8),
1354            NDDataBuffer::U8(_) => create_ds!(u8),
1355            NDDataBuffer::I16(_) => create_ds!(i16),
1356            NDDataBuffer::U16(_) => create_ds!(u16),
1357            NDDataBuffer::I32(_) => create_ds!(i32),
1358            NDDataBuffer::U32(_) => create_ds!(u32),
1359            NDDataBuffer::I64(_) => create_ds!(i64),
1360            NDDataBuffer::U64(_) => create_ds!(u64),
1361            NDDataBuffer::F32(_) => create_ds!(f32),
1362            NDDataBuffer::F64(_) => create_ds!(f64),
1363        };
1364
1365        if let Some(Hdf5Handle::Standard { primary, .. }) = self.handle.as_mut() {
1366            *primary = Some(ds);
1367        }
1368        self.open_data_type = Some(array.data.data_type());
1369        self.open_frame_dims = Some(frame_dims);
1370        self.open_extra_extent = extra_extent;
1371        Ok(())
1372    }
1373
1374    /// Write a frame in standard (non-SWMR) mode into the single extensible
1375    /// dataset, extending its leading dimension.
1376    fn write_standard(&mut self, array: &NDArray) -> ADResult<()> {
1377        if self.frame_count == 0 {
1378            self.create_primary_dataset(array)?;
1379            self.create_attribute_datasets(array);
1380        }
1381
1382        let frame_dims = self
1383            .open_frame_dims
1384            .clone()
1385            .ok_or_else(|| ADError::UnsupportedConversion("dataset not initialised".into()))?;
1386        let cur_dims: Vec<usize> = array.dims.iter().rev().map(|d| d.size).collect();
1387        if cur_dims != frame_dims {
1388            return Err(ADError::UnsupportedConversion(format!(
1389                "HDF5 frame shape changed mid-stream: {:?} != {:?}",
1390                cur_dims, frame_dims
1391            )));
1392        }
1393
1394        let (_shape, chunk, _extra) = self.primary_layout(&frame_dims);
1395        let frame_idx = self.frame_count;
1396        let extra_extent = self.open_extra_extent;
1397        let elem_size = array.data.data_type().element_size();
1398        let fc = chunk[0];
1399
1400        // With a fixed extra-dim layout, the frame counter must not exceed
1401        // the product of the extra-dim sizes.
1402        if let Some(total) = extra_extent {
1403            if frame_idx >= total {
1404                return Err(ADError::UnsupportedConversion(format!(
1405                    "HDF5 extra-dimension capacity exceeded: frame {} >= {}",
1406                    frame_idx, total
1407                )));
1408            }
1409        }
1410
1411        // The dataset declares a little-endian element type; serialize the
1412        // frame to LE explicitly rather than passing host-endian bytes
1413        // (see `nd_buffer_to_le_bytes`). Frames accumulate in a band buffer
1414        // and a full `fc`-deep band is flushed as a grid of write_chunk_at
1415        // tiles; close_file flushes the partial final band.
1416        self.frame_band.push(nd_buffer_to_le_bytes(&array.data));
1417        if self.frame_band.len() >= fc {
1418            let band_idx = frame_idx / fc;
1419            let ds = match self.handle {
1420                Some(Hdf5Handle::Standard {
1421                    primary: Some(ref ds),
1422                    ..
1423                }) => ds,
1424                _ => {
1425                    return Err(ADError::UnsupportedConversion(
1426                        "HDF5 primary dataset not initialised".into(),
1427                    ));
1428                }
1429            };
1430            Self::flush_band(
1431                ds,
1432                band_idx,
1433                &self.frame_band,
1434                &frame_dims,
1435                &chunk,
1436                elem_size,
1437            )?;
1438            self.frame_band.clear();
1439        }
1440
1441        // Append NDAttribute values for this frame.
1442        if self.store_attributes {
1443            for ad in self.attr_datasets.iter_mut() {
1444                let value = array
1445                    .attributes
1446                    .get(&ad.name)
1447                    .map(|a| a.value.clone())
1448                    .unwrap_or(NDAttrValue::Undefined);
1449                ad.push(&value);
1450            }
1451        }
1452        Ok(())
1453    }
1454
1455    /// Create one attribute time-series dataset per NDAttribute, preserving
1456    /// the NDAttrValue numeric type. Mirrors C++ `createAttributeDataset`.
1457    fn create_attribute_datasets(&mut self, array: &NDArray) {
1458        self.attr_datasets.clear();
1459        if !self.store_attributes {
1460            return;
1461        }
1462        for attr in array.attributes.iter() {
1463            let dt = attr.value.data_type();
1464            self.attr_datasets
1465                .push(AttributeDataset::new(attr.name.clone(), dt));
1466        }
1467    }
1468
1469    /// Flush accumulated NDAttribute datasets into the open standard file.
1470    /// Each becomes a chunked, extensible 1-D dataset under `NDAttributes/`.
1471    fn flush_attribute_datasets(&mut self) -> ADResult<()> {
1472        if self.attr_datasets.is_empty() {
1473            return Ok(());
1474        }
1475        let chunk_depth = self.chunk.ndattr_chunk.max(1);
1476        let ndattr_group = self.resolved_ndattr_group.clone();
1477        let h5file = match self.handle {
1478            Some(Hdf5Handle::Standard { ref file, .. }) => file,
1479            _ => return Ok(()),
1480        };
1481        // With a valid layout the `ndattr_default` group was already created
1482        // by `build_layout_groups`; re-open it. Without a layout, fall back
1483        // to a flat `NDAttributes` group at the file root.
1484        let group = if ndattr_group.is_empty() {
1485            h5file
1486                .create_group("NDAttributes")
1487                .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 group error: {}", e)))?
1488        } else {
1489            Self::open_write_group(h5file, &ndattr_group)?
1490        };
1491
1492        for ad in self.attr_datasets.iter() {
1493            if ad.frames == 0 {
1494                continue;
1495            }
1496            let n = ad.frames;
1497            let chunk = chunk_depth.min(n).max(1);
1498
1499            macro_rules! write_attr_ds {
1500                ($t:ty) => {{
1501                    let es = std::mem::size_of::<$t>();
1502                    let ds = group
1503                        .new_dataset::<$t>()
1504                        .shape(&[n])
1505                        .chunk(&[chunk])
1506                        .max_shape(&[None])
1507                        .create(&ad.name)
1508                        .map_err(|e| {
1509                            ADError::UnsupportedConversion(format!(
1510                                "HDF5 attribute dataset error: {}",
1511                                e
1512                            ))
1513                        })?;
1514                    // One chunk holds `chunk` consecutive frames; write each
1515                    // chunk's whole byte span (zero-padded for the trailing
1516                    // partial chunk, as rust-hdf5 requires full-chunk writes).
1517                    write_chunked_buffer(&ds, &ad.buffer, chunk * es)?;
1518                }};
1519            }
1520
1521            match ad.data_type {
1522                NDAttrDataType::Int8 => write_attr_ds!(i8),
1523                NDAttrDataType::UInt8 => write_attr_ds!(u8),
1524                NDAttrDataType::Int16 => write_attr_ds!(i16),
1525                NDAttrDataType::UInt16 => write_attr_ds!(u16),
1526                NDAttrDataType::Int32 => write_attr_ds!(i32),
1527                NDAttrDataType::UInt32 => write_attr_ds!(u32),
1528                NDAttrDataType::Int64 => write_attr_ds!(i64),
1529                NDAttrDataType::UInt64 => write_attr_ds!(u64),
1530                NDAttrDataType::Float32 => write_attr_ds!(f32),
1531                NDAttrDataType::Float64 => write_attr_ds!(f64),
1532                NDAttrDataType::String => {
1533                    // Fixed-width u8 field per frame.
1534                    let es = MAX_ATTRIBUTE_STRING_SIZE;
1535                    let ds = group
1536                        .new_dataset::<u8>()
1537                        .shape([n, es])
1538                        .chunk(&[chunk, es])
1539                        .max_shape(&[None, Some(es)])
1540                        .create(&ad.name)
1541                        .map_err(|e| {
1542                            ADError::UnsupportedConversion(format!(
1543                                "HDF5 attribute dataset error: {}",
1544                                e
1545                            ))
1546                        })?;
1547                    write_chunked_buffer(&ds, &ad.buffer, chunk * es)?;
1548                }
1549            }
1550        }
1551        Ok(())
1552    }
1553
1554    /// Write the `timestamp` performance dataset (`[nframes, 5]` doubles)
1555    /// into the open standard file. Mirrors C++ `writePerformanceDataset`.
1556    fn flush_performance_dataset(&mut self) -> ADResult<()> {
1557        if !self.store_performance || self.perf_rows.is_empty() {
1558            return Ok(());
1559        }
1560        let n = self.perf_rows.len();
1561        let mut flat: Vec<f64> = Vec::with_capacity(n * 5);
1562        for row in &self.perf_rows {
1563            flat.extend_from_slice(row);
1564        }
1565        // f64 doubles serialized explicitly little-endian to match the LE
1566        // datatype `rust-hdf5` records (write_chunk copies bytes verbatim).
1567        let raw: Vec<u8> = flat.iter().flat_map(|v| v.to_le_bytes()).collect();
1568
1569        let perf_group = self.resolved_perf_group.clone();
1570        let h5file = match self.handle {
1571            Some(Hdf5Handle::Standard { ref file, .. }) => file,
1572            _ => return Ok(()),
1573        };
1574        // With a valid layout the performance group (the group holding the
1575        // `timestamp` dataset) was already created by `build_layout_groups`;
1576        // re-open it. Without a layout, fall back to a flat `performance`
1577        // group at the file root.
1578        let group = if perf_group.is_empty() {
1579            h5file
1580                .create_group("performance")
1581                .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 group error: {}", e)))?
1582        } else {
1583            Self::open_write_group(h5file, &perf_group)?
1584        };
1585        let ds = group
1586            .new_dataset::<f64>()
1587            .shape([n, 5])
1588            .chunk(&[1, 5])
1589            .max_shape(&[None, Some(5)])
1590            .create("timestamp")
1591            .map_err(|e| {
1592                ADError::UnsupportedConversion(format!("HDF5 performance dataset error: {}", e))
1593            })?;
1594        for f in 0..n {
1595            let start = f * 5 * 8;
1596            let end = start + 5 * 8;
1597            ds.write_chunk(f, &raw[start..end]).map_err(|e| {
1598                ADError::UnsupportedConversion(format!("HDF5 performance write error: {}", e))
1599            })?;
1600        }
1601        Ok(())
1602    }
1603
1604    /// Write a frame in SWMR mode.
1605    fn write_swmr(&mut self, array: &NDArray) -> ADResult<()> {
1606        let (writer, ds_index) = match self.handle {
1607            Some(Hdf5Handle::Swmr {
1608                ref mut writer,
1609                ds_index,
1610                ..
1611            }) => (writer, ds_index),
1612            _ => return Err(ADError::UnsupportedConversion("no SWMR writer open".into())),
1613        };
1614
1615        // The SWMR streaming dataset declares a little-endian element type and
1616        // `append_frame` copies the supplied `&[u8]` verbatim; serialize to LE
1617        // explicitly (see `nd_buffer_to_le_bytes`) so the file is portable.
1618        let frame_bytes = nd_buffer_to_le_bytes(&array.data);
1619        writer
1620            .append_frame(ds_index, &frame_bytes)
1621            .map_err(|e| ADError::UnsupportedConversion(format!("SWMR append error: {}", e)))?;
1622
1623        // Periodic flush
1624        let count = self.frame_count + 1; // will be incremented after return
1625        if self.flush_nth_frame > 0 && count % self.flush_nth_frame == 0 {
1626            writer
1627                .flush()
1628                .map_err(|e| ADError::UnsupportedConversion(format!("SWMR flush error: {}", e)))?;
1629        }
1630        Ok(())
1631    }
1632
1633    /// Record one frame's I/O timing into the performance buffer.
1634    fn record_performance(&mut self, write_duration: f64, frame_bytes: usize) {
1635        let now = std::time::Instant::now();
1636        let first = *self.perf_first.get_or_insert(now);
1637        let runtime = now.duration_since(first).as_secs_f64();
1638        let period = match self.perf_prev {
1639            Some(prev) => now.duration_since(prev).as_secs_f64(),
1640            None => write_duration,
1641        };
1642        self.perf_prev = Some(now);
1643        let fb = frame_bytes as f64;
1644        let inst_speed = if period > 0.0 { fb / period } else { 0.0 };
1645        let avg_speed = if runtime > 0.0 {
1646            (self.perf_rows.len() as f64 + 1.0) * fb / runtime
1647        } else {
1648            0.0
1649        };
1650        self.perf_rows
1651            .push([write_duration, period, runtime, inst_speed, avg_speed]);
1652    }
1653}
1654
1655/// Serialize an NDArray data buffer to **little-endian** bytes.
1656///
1657/// `rust-hdf5` 0.2.15 records every numeric datatype message as little-endian
1658/// (`Endianness::LittleEndian`) and its only chunked-write API, `write_chunk`,
1659/// copies the supplied `&[u8]` verbatim into the chunk with no byte-swap.
1660/// `NDDataBuffer::as_u8_slice()` returns the buffer in *host* byte order, so
1661/// feeding it directly into a typed chunked dataset is correct only on a
1662/// little-endian host. This helper makes the on-disk bytes match the declared
1663/// LE datatype on every host: on LE it is a verbatim copy, on BE it swaps each
1664/// element. Used for every typed-dataset chunk write where no typed
1665/// chunked-write path exists in the crate.
1666fn nd_buffer_to_le_bytes(buf: &NDDataBuffer) -> Vec<u8> {
1667    match buf {
1668        NDDataBuffer::I8(v) => v.iter().map(|&x| x as u8).collect(),
1669        NDDataBuffer::U8(v) => v.clone(),
1670        NDDataBuffer::I16(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1671        NDDataBuffer::U16(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1672        NDDataBuffer::I32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1673        NDDataBuffer::U32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1674        NDDataBuffer::I64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1675        NDDataBuffer::U64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1676        NDDataBuffer::F32(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1677        NDDataBuffer::F64(v) => v.iter().flat_map(|&x| x.to_le_bytes()).collect(),
1678    }
1679}
1680
1681/// Write `buffer` into a chunked dataset, one `chunk_bytes`-sized chunk at a
1682/// time at consecutive linear indices. The trailing partial chunk is
1683/// zero-padded to a full chunk, which `rust-hdf5`'s `write_chunk` requires.
1684fn write_chunked_buffer(
1685    ds: &rust_hdf5::H5Dataset,
1686    buffer: &[u8],
1687    chunk_bytes: usize,
1688) -> ADResult<()> {
1689    let n_chunks = buffer.len().div_ceil(chunk_bytes.max(1));
1690    for c in 0..n_chunks {
1691        let start = c * chunk_bytes;
1692        let end = ((c + 1) * chunk_bytes).min(buffer.len());
1693        let slice = &buffer[start..end];
1694        if slice.len() == chunk_bytes {
1695            ds.write_chunk(c, slice)
1696        } else {
1697            let mut padded = vec![0u8; chunk_bytes];
1698            padded[..slice.len()].copy_from_slice(slice);
1699            ds.write_chunk(c, &padded)
1700        }
1701        .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 chunk write error: {}", e)))?;
1702    }
1703    Ok(())
1704}
1705
1706impl Default for Hdf5Writer {
1707    fn default() -> Self {
1708        Self::new()
1709    }
1710}
1711
1712impl NDFileWriter for Hdf5Writer {
1713    fn open_file(&mut self, path: &Path, mode: NDFileMode, array: &NDArray) -> ADResult<()> {
1714        self.current_path = Some(path.to_path_buf());
1715        self.frame_count = 0;
1716        self.frame_band.clear();
1717        self.total_runtime = 0.0;
1718        self.total_bytes = 0;
1719        self.swmr_cb_counter = 0;
1720        self.open_data_type = None;
1721        self.open_frame_dims = None;
1722        self.open_extra_extent = None;
1723        self.perf_rows.clear();
1724        self.perf_prev = None;
1725        self.perf_first = None;
1726        self.attr_datasets.clear();
1727        // Resolve where image/attribute/performance datasets land for this
1728        // file: the loaded layout XML tree, or the flat root default.
1729        self.resolve_layout_paths();
1730
1731        if self.swmr_mode && mode == NDFileMode::Stream {
1732            self.open_swmr(path, array)
1733        } else {
1734            let h5file = H5File::create(path)
1735                .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 create error: {}", e)))?;
1736            self.handle = Some(Hdf5Handle::Standard {
1737                file: h5file,
1738                primary: None,
1739            });
1740            Ok(())
1741        }
1742    }
1743
1744    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
1745        let start = std::time::Instant::now();
1746
1747        let is_swmr = matches!(self.handle, Some(Hdf5Handle::Swmr { .. }));
1748        if is_swmr {
1749            self.write_swmr(array)?;
1750        } else {
1751            self.write_standard(array)?;
1752        }
1753        self.frame_count += 1;
1754
1755        let elapsed = start.elapsed().as_secs_f64();
1756        let frame_bytes = array.data.as_u8_slice().len();
1757        if self.store_performance {
1758            self.total_runtime += elapsed;
1759            self.total_bytes += frame_bytes as u64;
1760            self.record_performance(elapsed, frame_bytes);
1761        }
1762        Ok(())
1763    }
1764
1765    fn read_file(&mut self) -> ADResult<NDArray> {
1766        // The image dataset lives at the layout-resolved path (flat `data`
1767        // by default, or the nested layout path). Resolve it so read-back
1768        // tracks the same placement as the write path.
1769        self.resolve_layout_paths();
1770        let dataset_path = self.resolved_dataset_path.clone();
1771        let path = self
1772            .current_path
1773            .as_ref()
1774            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
1775
1776        let h5file = H5File::open(path)
1777            .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 open error: {}", e)))?;
1778
1779        let ds = h5file
1780            .dataset(&dataset_path)
1781            .map_err(|e| ADError::UnsupportedConversion(format!("HDF5 dataset error: {}", e)))?;
1782
1783        let shape = ds.shape();
1784        let dims: Vec<NDDimension> = shape.iter().rev().map(|&s| NDDimension::new(s)).collect();
1785        let element_size = ds.element_size();
1786
1787        // Prefer the exact data type recorded at write time.
1788        let recorded: Option<NDDataType> = ds
1789            .attr(DTYPE_ATTR)
1790            .ok()
1791            .and_then(|a| a.read_numeric::<i32>().ok())
1792            .and_then(|v| NDDataType::from_ordinal(v as u8));
1793
1794        let data_type = recorded.unwrap_or(match element_size {
1795            1 => NDDataType::UInt8,
1796            2 => NDDataType::UInt16,
1797            4 => NDDataType::Float32,
1798            8 => NDDataType::Float64,
1799            other => {
1800                return Err(ADError::UnsupportedConversion(format!(
1801                    "unsupported HDF5 element size {}",
1802                    other
1803                )));
1804            }
1805        });
1806
1807        macro_rules! read_typed {
1808            ($t:ty, $variant:ident) => {{
1809                let data = ds.read_raw::<$t>().map_err(|e| {
1810                    ADError::UnsupportedConversion(format!("HDF5 read error: {}", e))
1811                })?;
1812                let mut arr = NDArray::new(dims, data_type);
1813                arr.data = NDDataBuffer::$variant(data);
1814                return Ok(arr);
1815            }};
1816        }
1817
1818        match data_type {
1819            NDDataType::Int8 => read_typed!(i8, I8),
1820            NDDataType::UInt8 => read_typed!(u8, U8),
1821            NDDataType::Int16 => read_typed!(i16, I16),
1822            NDDataType::UInt16 => read_typed!(u16, U16),
1823            NDDataType::Int32 => read_typed!(i32, I32),
1824            NDDataType::UInt32 => read_typed!(u32, U32),
1825            NDDataType::Int64 => read_typed!(i64, I64),
1826            NDDataType::UInt64 => read_typed!(u64, U64),
1827            NDDataType::Float32 => read_typed!(f32, F32),
1828            NDDataType::Float64 => read_typed!(f64, F64),
1829        }
1830    }
1831
1832    fn close_file(&mut self) -> ADResult<()> {
1833        match self.handle {
1834            Some(Hdf5Handle::Standard { .. }) => {
1835                // Flush the partial frame band and trim the logical extent,
1836                // then emit the accumulated attribute and performance
1837                // datasets before the file is finalised.
1838                self.finalize_standard_primary()?;
1839                self.flush_attribute_datasets()?;
1840                self.flush_performance_dataset()?;
1841                // Materialise layout `<hardlink>` elements last, once every
1842                // dataset a link may target exists on disk.
1843                match self.handle {
1844                    Some(Hdf5Handle::Standard { ref file, .. }) => {
1845                        self.build_layout_hardlinks(file)?
1846                    }
1847                    _ => unreachable!("handle is Standard in this arm"),
1848                }
1849                self.handle = None;
1850            }
1851            Some(Hdf5Handle::Swmr { .. }) => {
1852                // The layout group tree, the nested dataset placement and the
1853                // layout `<hardlink>` elements were all materialised in
1854                // `open_swmr` before `start_swmr()` (C `NDFileHDF5.cpp:320`-
1855                // `326`: `createHardLinks` then `startSWMR`), so SWMR readers
1856                // see them for the whole streaming window. Closing the writer
1857                // only finalises the streamed frames.
1858                if let Some(Hdf5Handle::Swmr { writer, .. }) = self.handle.take() {
1859                    writer.close().map_err(|e| {
1860                        ADError::UnsupportedConversion(format!("SWMR close error: {}", e))
1861                    })?;
1862                }
1863            }
1864            None => {}
1865        }
1866        self.current_path = None;
1867        Ok(())
1868    }
1869
1870    fn supports_multiple_arrays(&self) -> bool {
1871        true
1872    }
1873}
1874
1875// ============================================================
1876// Processor
1877// ============================================================
1878
1879/// Param indices for HDF5-specific params.
1880#[derive(Default)]
1881struct Hdf5ParamIndices {
1882    compression_type: Option<usize>,
1883    z_compress_level: Option<usize>,
1884    szip_num_pixels: Option<usize>,
1885    nbit_precision: Option<usize>,
1886    nbit_offset: Option<usize>,
1887    jpeg_quality: Option<usize>,
1888    blosc_shuffle_type: Option<usize>,
1889    blosc_compressor: Option<usize>,
1890    blosc_compress_level: Option<usize>,
1891    store_attributes: Option<usize>,
1892    store_performance: Option<usize>,
1893    total_runtime: Option<usize>,
1894    total_io_speed: Option<usize>,
1895    swmr_mode: Option<usize>,
1896    swmr_flush_now: Option<usize>,
1897    swmr_running: Option<usize>,
1898    swmr_cb_counter: Option<usize>,
1899    swmr_supported: Option<usize>,
1900    flush_nth_frame: Option<usize>,
1901    chunk_size_auto: Option<usize>,
1902    n_row_chunks: Option<usize>,
1903    n_col_chunks: Option<usize>,
1904    n_frames_chunks: Option<usize>,
1905    ndattr_chunk: Option<usize>,
1906    n_extra_dims: Option<usize>,
1907    extra_dim_size: [Option<usize>; MAX_EXTRA_DIMS],
1908    extra_dim_name: [Option<usize>; MAX_EXTRA_DIMS],
1909    fill_value: Option<usize>,
1910    dim_att_datasets: Option<usize>,
1911    layout_filename: Option<usize>,
1912    layout_valid: Option<usize>,
1913    layout_error_msg: Option<usize>,
1914}
1915
1916/// HDF5 file processor wrapping FilePluginController<Hdf5Writer>.
1917pub struct Hdf5FileProcessor {
1918    ctrl: FilePluginController<Hdf5Writer>,
1919    hdf5_params: Hdf5ParamIndices,
1920}
1921
1922impl Hdf5FileProcessor {
1923    pub fn new() -> Self {
1924        Self {
1925            ctrl: FilePluginController::new(Hdf5Writer::new()),
1926            hdf5_params: Hdf5ParamIndices::default(),
1927        }
1928    }
1929
1930    pub fn set_dataset_name(&mut self, name: &str) {
1931        self.ctrl.writer.set_dataset_name(name);
1932    }
1933}
1934
1935/// Register all HDF5-specific params.
1936fn register_hdf5_params(
1937    base: &mut asyn_rs::port::PortDriverBase,
1938) -> asyn_rs::error::AsynResult<()> {
1939    use asyn_rs::param::ParamType;
1940    base.create_param("HDF5_SWMRFlushNow", ParamType::Int32)?;
1941    base.create_param("HDF5_chunkSizeAuto", ParamType::Int32)?;
1942    base.create_param("HDF5_nRowChunks", ParamType::Int32)?;
1943    base.create_param("HDF5_nColChunks", ParamType::Int32)?;
1944    base.create_param("HDF5_chunkSize2", ParamType::Int32)?;
1945    base.create_param("HDF5_chunkSize3", ParamType::Int32)?;
1946    base.create_param("HDF5_chunkSize4", ParamType::Int32)?;
1947    base.create_param("HDF5_chunkSize5", ParamType::Int32)?;
1948    base.create_param("HDF5_chunkSize6", ParamType::Int32)?;
1949    base.create_param("HDF5_chunkSize7", ParamType::Int32)?;
1950    base.create_param("HDF5_chunkSize8", ParamType::Int32)?;
1951    base.create_param("HDF5_chunkSize9", ParamType::Int32)?;
1952    base.create_param("HDF5_nFramesChunks", ParamType::Int32)?;
1953    base.create_param("HDF5_NDAttributeChunk", ParamType::Int32)?;
1954    base.create_param("HDF5_chunkBoundaryAlign", ParamType::Int32)?;
1955    base.create_param("HDF5_chunkBoundaryThreshold", ParamType::Int32)?;
1956    base.create_param("HDF5_nExtraDims", ParamType::Int32)?;
1957    base.create_param("HDF5_extraDimSizeN", ParamType::Int32)?;
1958    base.create_param("HDF5_extraDimNameN", ParamType::Octet)?;
1959    base.create_param("HDF5_extraDimSizeX", ParamType::Int32)?;
1960    base.create_param("HDF5_extraDimNameX", ParamType::Octet)?;
1961    base.create_param("HDF5_extraDimSizeY", ParamType::Int32)?;
1962    base.create_param("HDF5_extraDimNameY", ParamType::Octet)?;
1963    base.create_param("HDF5_extraDimSize3", ParamType::Int32)?;
1964    base.create_param("HDF5_extraDimName3", ParamType::Octet)?;
1965    base.create_param("HDF5_extraDimSize4", ParamType::Int32)?;
1966    base.create_param("HDF5_extraDimName4", ParamType::Octet)?;
1967    base.create_param("HDF5_extraDimSize5", ParamType::Int32)?;
1968    base.create_param("HDF5_extraDimName5", ParamType::Octet)?;
1969    base.create_param("HDF5_extraDimSize6", ParamType::Int32)?;
1970    base.create_param("HDF5_extraDimName6", ParamType::Octet)?;
1971    base.create_param("HDF5_extraDimSize7", ParamType::Int32)?;
1972    base.create_param("HDF5_extraDimName7", ParamType::Octet)?;
1973    base.create_param("HDF5_extraDimSize8", ParamType::Int32)?;
1974    base.create_param("HDF5_extraDimName8", ParamType::Octet)?;
1975    base.create_param("HDF5_extraDimSize9", ParamType::Int32)?;
1976    base.create_param("HDF5_extraDimName9", ParamType::Octet)?;
1977    base.create_param("HDF5_storeAttributes", ParamType::Int32)?;
1978    base.create_param("HDF5_storePerformance", ParamType::Int32)?;
1979    base.create_param("HDF5_totalRuntime", ParamType::Float64)?;
1980    base.create_param("HDF5_totalIoSpeed", ParamType::Float64)?;
1981    base.create_param("HDF5_flushNthFrame", ParamType::Int32)?;
1982    base.create_param("HDF5_compressionType", ParamType::Int32)?;
1983    base.create_param("HDF5_nbitsPrecision", ParamType::Int32)?;
1984    base.create_param("HDF5_nbitsOffset", ParamType::Int32)?;
1985    base.create_param("HDF5_szipNumPixels", ParamType::Int32)?;
1986    base.create_param("HDF5_zCompressLevel", ParamType::Int32)?;
1987    base.create_param("HDF5_bloscShuffleType", ParamType::Int32)?;
1988    base.create_param("HDF5_bloscCompressor", ParamType::Int32)?;
1989    base.create_param("HDF5_bloscCompressLevel", ParamType::Int32)?;
1990    base.create_param("HDF5_jpegQuality", ParamType::Int32)?;
1991    base.create_param("HDF5_dimAttDatasets", ParamType::Int32)?;
1992    base.create_param("HDF5_layoutErrorMsg", ParamType::Octet)?;
1993    base.create_param("HDF5_layoutValid", ParamType::Int32)?;
1994    base.create_param("HDF5_layoutFilename", ParamType::Octet)?;
1995    base.create_param("HDF5_SWMRSupported", ParamType::Int32)?;
1996    base.create_param("HDF5_SWMRMode", ParamType::Int32)?;
1997    base.create_param("HDF5_SWMRRunning", ParamType::Int32)?;
1998    base.create_param("HDF5_SWMRCbCounter", ParamType::Int32)?;
1999    base.create_param("HDF5_posRunning", ParamType::Int32)?;
2000    base.create_param("HDF5_posNameDimN", ParamType::Octet)?;
2001    base.create_param("HDF5_posNameDimX", ParamType::Octet)?;
2002    base.create_param("HDF5_posNameDimY", ParamType::Octet)?;
2003    base.create_param("HDF5_posNameDim3", ParamType::Octet)?;
2004    base.create_param("HDF5_posNameDim4", ParamType::Octet)?;
2005    base.create_param("HDF5_posNameDim5", ParamType::Octet)?;
2006    base.create_param("HDF5_posNameDim6", ParamType::Octet)?;
2007    base.create_param("HDF5_posNameDim7", ParamType::Octet)?;
2008    base.create_param("HDF5_posNameDim8", ParamType::Octet)?;
2009    base.create_param("HDF5_posNameDim9", ParamType::Octet)?;
2010    base.create_param("HDF5_posIndexDimN", ParamType::Octet)?;
2011    base.create_param("HDF5_posIndexDimX", ParamType::Octet)?;
2012    base.create_param("HDF5_posIndexDimY", ParamType::Octet)?;
2013    base.create_param("HDF5_posIndexDim3", ParamType::Octet)?;
2014    base.create_param("HDF5_posIndexDim4", ParamType::Octet)?;
2015    base.create_param("HDF5_posIndexDim5", ParamType::Octet)?;
2016    base.create_param("HDF5_posIndexDim6", ParamType::Octet)?;
2017    base.create_param("HDF5_posIndexDim7", ParamType::Octet)?;
2018    base.create_param("HDF5_posIndexDim8", ParamType::Octet)?;
2019    base.create_param("HDF5_posIndexDim9", ParamType::Octet)?;
2020    base.create_param("HDF5_fillValue", ParamType::Float64)?;
2021    base.create_param("HDF5_extraDimChunkX", ParamType::Int32)?;
2022    base.create_param("HDF5_extraDimChunkY", ParamType::Int32)?;
2023    base.create_param("HDF5_extraDimChunk3", ParamType::Int32)?;
2024    base.create_param("HDF5_extraDimChunk4", ParamType::Int32)?;
2025    base.create_param("HDF5_extraDimChunk5", ParamType::Int32)?;
2026    base.create_param("HDF5_extraDimChunk6", ParamType::Int32)?;
2027    base.create_param("HDF5_extraDimChunk7", ParamType::Int32)?;
2028    base.create_param("HDF5_extraDimChunk8", ParamType::Int32)?;
2029    base.create_param("HDF5_extraDimChunk9", ParamType::Int32)?;
2030    Ok(())
2031}
2032
2033impl Default for Hdf5FileProcessor {
2034    fn default() -> Self {
2035        Self::new()
2036    }
2037}
2038
2039/// Names of the `HDF5_extraDimSizeN..9` params in slot order.
2040const EXTRA_DIM_SIZE_PARAMS: [&str; MAX_EXTRA_DIMS] = [
2041    "HDF5_extraDimSizeN",
2042    "HDF5_extraDimSizeX",
2043    "HDF5_extraDimSizeY",
2044    "HDF5_extraDimSize3",
2045    "HDF5_extraDimSize4",
2046    "HDF5_extraDimSize5",
2047    "HDF5_extraDimSize6",
2048    "HDF5_extraDimSize7",
2049    "HDF5_extraDimSize8",
2050    "HDF5_extraDimSize9",
2051];
2052
2053/// Names of the `HDF5_extraDimNameN..9` params in slot order.
2054const EXTRA_DIM_NAME_PARAMS: [&str; MAX_EXTRA_DIMS] = [
2055    "HDF5_extraDimNameN",
2056    "HDF5_extraDimNameX",
2057    "HDF5_extraDimNameY",
2058    "HDF5_extraDimName3",
2059    "HDF5_extraDimName4",
2060    "HDF5_extraDimName5",
2061    "HDF5_extraDimName6",
2062    "HDF5_extraDimName7",
2063    "HDF5_extraDimName8",
2064    "HDF5_extraDimName9",
2065];
2066
2067impl NDPluginProcess for Hdf5FileProcessor {
2068    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
2069        let was_swmr = self.ctrl.writer.is_swmr_active();
2070        let mut result = self.ctrl.process_array(array);
2071        let is_swmr = self.ctrl.writer.is_swmr_active();
2072
2073        // SWMR running status changed
2074        if was_swmr != is_swmr {
2075            if let Some(idx) = self.hdf5_params.swmr_running {
2076                result
2077                    .param_updates
2078                    .push(ParamUpdate::int32(idx, if is_swmr { 1 } else { 0 }));
2079            }
2080        }
2081
2082        // SWMR callback counter
2083        if is_swmr {
2084            if let Some(idx) = self.hdf5_params.swmr_cb_counter {
2085                result.param_updates.push(ParamUpdate::int32(
2086                    idx,
2087                    self.ctrl.writer.swmr_cb_counter as i32,
2088                ));
2089            }
2090        }
2091
2092        // Performance stats
2093        if self.ctrl.writer.store_performance {
2094            if let Some(idx) = self.hdf5_params.total_runtime {
2095                result
2096                    .param_updates
2097                    .push(ParamUpdate::float64(idx, self.ctrl.writer.total_runtime));
2098            }
2099            if let Some(idx) = self.hdf5_params.total_io_speed {
2100                let speed = if self.ctrl.writer.total_runtime > 0.0 {
2101                    self.ctrl.writer.total_bytes as f64
2102                        / self.ctrl.writer.total_runtime
2103                        / 1_000_000.0
2104                } else {
2105                    0.0
2106                };
2107                result.param_updates.push(ParamUpdate::float64(idx, speed));
2108            }
2109        }
2110
2111        result
2112    }
2113
2114    fn plugin_type(&self) -> &str {
2115        "NDFileHDF5"
2116    }
2117
2118    fn register_params(
2119        &mut self,
2120        base: &mut asyn_rs::port::PortDriverBase,
2121    ) -> asyn_rs::error::AsynResult<()> {
2122        self.ctrl.register_params(base)?;
2123        register_hdf5_params(base)?;
2124        self.hdf5_params.compression_type = base.find_param("HDF5_compressionType");
2125        self.hdf5_params.z_compress_level = base.find_param("HDF5_zCompressLevel");
2126        self.hdf5_params.szip_num_pixels = base.find_param("HDF5_szipNumPixels");
2127        self.hdf5_params.nbit_precision = base.find_param("HDF5_nbitsPrecision");
2128        self.hdf5_params.nbit_offset = base.find_param("HDF5_nbitsOffset");
2129        self.hdf5_params.jpeg_quality = base.find_param("HDF5_jpegQuality");
2130        self.hdf5_params.blosc_shuffle_type = base.find_param("HDF5_bloscShuffleType");
2131        self.hdf5_params.blosc_compressor = base.find_param("HDF5_bloscCompressor");
2132        self.hdf5_params.blosc_compress_level = base.find_param("HDF5_bloscCompressLevel");
2133        self.hdf5_params.store_attributes = base.find_param("HDF5_storeAttributes");
2134        self.hdf5_params.store_performance = base.find_param("HDF5_storePerformance");
2135        self.hdf5_params.total_runtime = base.find_param("HDF5_totalRuntime");
2136        self.hdf5_params.total_io_speed = base.find_param("HDF5_totalIoSpeed");
2137        self.hdf5_params.swmr_mode = base.find_param("HDF5_SWMRMode");
2138        self.hdf5_params.swmr_flush_now = base.find_param("HDF5_SWMRFlushNow");
2139        self.hdf5_params.swmr_running = base.find_param("HDF5_SWMRRunning");
2140        self.hdf5_params.swmr_cb_counter = base.find_param("HDF5_SWMRCbCounter");
2141        self.hdf5_params.swmr_supported = base.find_param("HDF5_SWMRSupported");
2142        self.hdf5_params.flush_nth_frame = base.find_param("HDF5_flushNthFrame");
2143        self.hdf5_params.chunk_size_auto = base.find_param("HDF5_chunkSizeAuto");
2144        self.hdf5_params.n_row_chunks = base.find_param("HDF5_nRowChunks");
2145        self.hdf5_params.n_col_chunks = base.find_param("HDF5_nColChunks");
2146        self.hdf5_params.n_frames_chunks = base.find_param("HDF5_nFramesChunks");
2147        self.hdf5_params.ndattr_chunk = base.find_param("HDF5_NDAttributeChunk");
2148        self.hdf5_params.n_extra_dims = base.find_param("HDF5_nExtraDims");
2149        for i in 0..MAX_EXTRA_DIMS {
2150            self.hdf5_params.extra_dim_size[i] = base.find_param(EXTRA_DIM_SIZE_PARAMS[i]);
2151            self.hdf5_params.extra_dim_name[i] = base.find_param(EXTRA_DIM_NAME_PARAMS[i]);
2152        }
2153        self.hdf5_params.fill_value = base.find_param("HDF5_fillValue");
2154        self.hdf5_params.dim_att_datasets = base.find_param("HDF5_dimAttDatasets");
2155        self.hdf5_params.layout_filename = base.find_param("HDF5_layoutFilename");
2156        self.hdf5_params.layout_valid = base.find_param("HDF5_layoutValid");
2157        self.hdf5_params.layout_error_msg = base.find_param("HDF5_layoutErrorMsg");
2158
2159        // Report SWMR as always supported
2160        if let Some(idx) = self.hdf5_params.swmr_supported {
2161            base.set_int32_param(idx, 0, 1)?;
2162        }
2163        Ok(())
2164    }
2165
2166    fn on_param_change(
2167        &mut self,
2168        reason: usize,
2169        params: &PluginParamSnapshot,
2170    ) -> ParamChangeResult {
2171        // -- compression params --
2172        if Some(reason) == self.hdf5_params.compression_type {
2173            self.ctrl.writer.set_compression_type(params.value.as_i32());
2174            return ParamChangeResult::updates(vec![]);
2175        }
2176        if Some(reason) == self.hdf5_params.z_compress_level {
2177            self.ctrl
2178                .writer
2179                .set_z_compress_level(params.value.as_i32() as u32);
2180            return ParamChangeResult::updates(vec![]);
2181        }
2182        if Some(reason) == self.hdf5_params.szip_num_pixels {
2183            self.ctrl
2184                .writer
2185                .set_szip_num_pixels(params.value.as_i32() as u32);
2186            return ParamChangeResult::updates(vec![]);
2187        }
2188        if Some(reason) == self.hdf5_params.blosc_shuffle_type {
2189            self.ctrl
2190                .writer
2191                .set_blosc_shuffle_type(params.value.as_i32());
2192            return ParamChangeResult::updates(vec![]);
2193        }
2194        if Some(reason) == self.hdf5_params.blosc_compressor {
2195            self.ctrl.writer.set_blosc_compressor(params.value.as_i32());
2196            return ParamChangeResult::updates(vec![]);
2197        }
2198        if Some(reason) == self.hdf5_params.blosc_compress_level {
2199            self.ctrl
2200                .writer
2201                .set_blosc_compress_level(params.value.as_i32() as u32);
2202            return ParamChangeResult::updates(vec![]);
2203        }
2204        if Some(reason) == self.hdf5_params.nbit_precision {
2205            self.ctrl
2206                .writer
2207                .set_nbit_precision(params.value.as_i32() as u32);
2208            return ParamChangeResult::updates(vec![]);
2209        }
2210        if Some(reason) == self.hdf5_params.nbit_offset {
2211            self.ctrl
2212                .writer
2213                .set_nbit_offset(params.value.as_i32() as u32);
2214            return ParamChangeResult::updates(vec![]);
2215        }
2216        if Some(reason) == self.hdf5_params.jpeg_quality {
2217            self.ctrl
2218                .writer
2219                .set_jpeg_quality(params.value.as_i32() as u32);
2220            return ParamChangeResult::updates(vec![]);
2221        }
2222        if Some(reason) == self.hdf5_params.store_attributes {
2223            self.ctrl
2224                .writer
2225                .set_store_attributes(params.value.as_i32() != 0);
2226            return ParamChangeResult::updates(vec![]);
2227        }
2228        if Some(reason) == self.hdf5_params.store_performance {
2229            self.ctrl
2230                .writer
2231                .set_store_performance(params.value.as_i32() != 0);
2232            return ParamChangeResult::updates(vec![]);
2233        }
2234        // -- chunking params --
2235        if Some(reason) == self.hdf5_params.chunk_size_auto {
2236            self.ctrl
2237                .writer
2238                .set_chunk_size_auto(params.value.as_i32() != 0);
2239            return ParamChangeResult::updates(vec![]);
2240        }
2241        if Some(reason) == self.hdf5_params.n_row_chunks {
2242            self.ctrl
2243                .writer
2244                .set_n_row_chunks(params.value.as_i32().max(0) as usize);
2245            return ParamChangeResult::updates(vec![]);
2246        }
2247        if Some(reason) == self.hdf5_params.n_col_chunks {
2248            self.ctrl
2249                .writer
2250                .set_n_col_chunks(params.value.as_i32().max(0) as usize);
2251            return ParamChangeResult::updates(vec![]);
2252        }
2253        if Some(reason) == self.hdf5_params.n_frames_chunks {
2254            self.ctrl
2255                .writer
2256                .set_n_frames_chunks(params.value.as_i32().max(0) as usize);
2257            return ParamChangeResult::updates(vec![]);
2258        }
2259        if Some(reason) == self.hdf5_params.ndattr_chunk {
2260            self.ctrl
2261                .writer
2262                .set_ndattr_chunk(params.value.as_i32().max(1) as usize);
2263            return ParamChangeResult::updates(vec![]);
2264        }
2265        // -- extra dimensions --
2266        if Some(reason) == self.hdf5_params.n_extra_dims {
2267            self.ctrl
2268                .writer
2269                .set_n_extra_dims(params.value.as_i32().max(0) as usize);
2270            return ParamChangeResult::updates(vec![]);
2271        }
2272        for i in 0..MAX_EXTRA_DIMS {
2273            if Some(reason) == self.hdf5_params.extra_dim_size[i] {
2274                self.ctrl
2275                    .writer
2276                    .set_extra_dim_size(i, params.value.as_i32().max(1) as usize);
2277                return ParamChangeResult::updates(vec![]);
2278            }
2279            if Some(reason) == self.hdf5_params.extra_dim_name[i] {
2280                self.ctrl
2281                    .writer
2282                    .set_extra_dim_name(i, params.value.as_string().unwrap_or(""));
2283                return ParamChangeResult::updates(vec![]);
2284            }
2285        }
2286        if Some(reason) == self.hdf5_params.fill_value {
2287            self.ctrl.writer.set_fill_value(params.value.as_f64());
2288            return ParamChangeResult::updates(vec![]);
2289        }
2290        if Some(reason) == self.hdf5_params.dim_att_datasets {
2291            self.ctrl
2292                .writer
2293                .set_dim_att_datasets(params.value.as_i32() != 0);
2294            return ParamChangeResult::updates(vec![]);
2295        }
2296        // -- layout XML --
2297        if Some(reason) == self.hdf5_params.layout_filename {
2298            let path = params.value.as_string().unwrap_or("").to_string();
2299            self.ctrl.writer.set_layout_filename(&path);
2300            let mut updates = vec![];
2301            if let Some(idx) = self.hdf5_params.layout_valid {
2302                updates.push(ParamUpdate::int32(
2303                    idx,
2304                    if self.ctrl.writer.layout_valid { 1 } else { 0 },
2305                ));
2306            }
2307            if let Some(idx) = self.hdf5_params.layout_error_msg {
2308                updates.push(ParamUpdate::Octet {
2309                    reason: idx,
2310                    addr: 0,
2311                    value: self.ctrl.writer.layout_error.clone(),
2312                });
2313            }
2314            return ParamChangeResult::updates(updates);
2315        }
2316        // -- SWMR params --
2317        if Some(reason) == self.hdf5_params.swmr_mode {
2318            self.ctrl.writer.set_swmr_mode(params.value.as_i32() != 0);
2319            return ParamChangeResult::updates(vec![]);
2320        }
2321        if Some(reason) == self.hdf5_params.swmr_flush_now {
2322            if params.value.as_i32() != 0 {
2323                self.ctrl.writer.flush_swmr();
2324                let mut updates = vec![];
2325                if let Some(idx) = self.hdf5_params.swmr_cb_counter {
2326                    updates.push(ParamUpdate::int32(
2327                        idx,
2328                        self.ctrl.writer.swmr_cb_counter as i32,
2329                    ));
2330                }
2331                return ParamChangeResult::updates(updates);
2332            }
2333            return ParamChangeResult::updates(vec![]);
2334        }
2335        if Some(reason) == self.hdf5_params.flush_nth_frame {
2336            self.ctrl
2337                .writer
2338                .set_flush_nth_frame(params.value.as_i32().max(0) as usize);
2339            return ParamChangeResult::updates(vec![]);
2340        }
2341        self.ctrl.on_param_change(reason, params)
2342    }
2343}
2344
2345#[cfg(test)]
2346mod tests {
2347    use super::*;
2348    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
2349    use std::sync::atomic::{AtomicU32, Ordering};
2350
2351    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
2352
2353    fn temp_path(prefix: &str) -> PathBuf {
2354        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
2355        std::env::temp_dir().join(format!("adcore_test_{}_{}.h5", prefix, n))
2356    }
2357
2358    #[test]
2359    fn test_write_single_frame() {
2360        let path = temp_path("hdf5_single");
2361        let mut writer = Hdf5Writer::new();
2362
2363        let mut arr = NDArray::new(
2364            vec![NDDimension::new(4), NDDimension::new(4)],
2365            NDDataType::UInt8,
2366        );
2367        if let NDDataBuffer::U8(ref mut v) = arr.data {
2368            for i in 0..16 {
2369                v[i] = i as u8;
2370            }
2371        }
2372
2373        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
2374        writer.write_file(&arr).unwrap();
2375        writer.close_file().unwrap();
2376
2377        // Single-frame standard mode: dataset is [1, 4, 4].
2378        let h5 = H5File::open(&path).unwrap();
2379        let ds = h5.dataset("data").unwrap();
2380        assert_eq!(ds.shape(), vec![1, 4, 4]);
2381        let data: Vec<u8> = ds.read_raw().unwrap();
2382        assert_eq!(data[0], 0);
2383        assert_eq!(data[15], 15);
2384        drop(h5);
2385
2386        let mut reader = Hdf5Writer::new();
2387        reader.current_path = Some(path.clone());
2388        let read_arr = reader.read_file().unwrap();
2389        assert_eq!(read_arr.dims.len(), 3);
2390        assert_eq!(read_arr.dims[2].size, 1); // leading frame dim
2391
2392        std::fs::remove_file(&path).ok();
2393    }
2394
2395    #[test]
2396    fn test_write_multiple_frames() {
2397        let path = temp_path("hdf5_multi");
2398        let mut writer = Hdf5Writer::new();
2399
2400        let mut arr = NDArray::new(
2401            vec![NDDimension::new(4), NDDimension::new(4)],
2402            NDDataType::UInt8,
2403        );
2404        // Mark each frame distinctly so we can verify per-frame placement.
2405        for f in 0..3u8 {
2406            if let NDDataBuffer::U8(ref mut v) = arr.data {
2407                for x in v.iter_mut() {
2408                    *x = f;
2409                }
2410            }
2411            if f == 0 {
2412                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2413            }
2414            writer.write_file(&arr).unwrap();
2415        }
2416        writer.close_file().unwrap();
2417
2418        assert!(writer.supports_multiple_arrays());
2419        assert_eq!(writer.frame_count(), 3);
2420
2421        let data = std::fs::read(&path).unwrap();
2422        assert_eq!(&data[0..8], b"\x89HDF\r\n\x1a\n");
2423
2424        // Single extensible dataset [3, 4, 4] — NOT one dataset per frame.
2425        let h5 = H5File::open(&path).unwrap();
2426        let names = h5.dataset_names();
2427        assert!(names.contains(&"data".to_string()));
2428        assert!(
2429            !names.contains(&"data_1".to_string()),
2430            "must not write per-frame datasets"
2431        );
2432        let ds = h5.dataset("data").unwrap();
2433        assert_eq!(
2434            ds.shape(),
2435            vec![3, 4, 4],
2436            "rank/shape must be [nframes,Y,X]"
2437        );
2438        let raw: Vec<u8> = ds.read_raw().unwrap();
2439        assert_eq!(raw.len(), 3 * 4 * 4);
2440        // Frame 0 all zeros, frame 1 all ones, frame 2 all twos.
2441        assert_eq!(raw[0], 0);
2442        assert_eq!(raw[16], 1);
2443        assert_eq!(raw[32], 2);
2444
2445        std::fs::remove_file(&path).ok();
2446    }
2447
2448    #[test]
2449    fn test_sub_frame_chunking() {
2450        // nRowChunks/nColChunks that divide the frame produce a sub-frame
2451        // chunk grid written via write_chunk_at tiles; the dataset shape
2452        // stays exactly [N, Y, X] (no padding) and the data round-trips.
2453        let path = temp_path("hdf5_subchunk");
2454        let mut writer = Hdf5Writer::new();
2455        writer.set_chunk_size_auto(false); // honor explicit chunk sizes
2456        writer.set_n_row_chunks(4); // Y = 8 → 2 row tiles
2457        writer.set_n_col_chunks(4); // X = 8 → 2 col tiles
2458
2459        let mut arr = NDArray::new(
2460            vec![NDDimension::new(8), NDDimension::new(8)],
2461            NDDataType::UInt16,
2462        );
2463        for f in 0..3u16 {
2464            if let NDDataBuffer::U16(ref mut v) = arr.data {
2465                for (i, x) in v.iter_mut().enumerate() {
2466                    *x = f * 1000 + i as u16;
2467                }
2468            }
2469            if f == 0 {
2470                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2471            }
2472            writer.write_file(&arr).unwrap();
2473        }
2474        writer.close_file().unwrap();
2475
2476        let h5 = H5File::open(&path).unwrap();
2477        let ds = h5.dataset("data").unwrap();
2478        assert_eq!(ds.shape(), vec![3, 8, 8], "shape must not be chunk-padded");
2479        assert_eq!(
2480            ds.chunk_dims(),
2481            Some(vec![1, 4, 4]),
2482            "chunk grid must be the sub-frame tile size"
2483        );
2484        let raw: Vec<u16> = ds.read_raw().unwrap();
2485        assert_eq!(raw.len(), 3 * 64);
2486        for f in 0..3u16 {
2487            for i in 0..64usize {
2488                assert_eq!(
2489                    raw[f as usize * 64 + i],
2490                    f * 1000 + i as u16,
2491                    "frame {} elem {}",
2492                    f,
2493                    i
2494                );
2495            }
2496        }
2497
2498        std::fs::remove_file(&path).ok();
2499    }
2500
2501    #[test]
2502    fn test_sub_frame_chunking_with_compression() {
2503        // Sub-frame chunk tiles must round-trip through a filter pipeline:
2504        // each write_chunk_at tile is compressed independently.
2505        let path = temp_path("hdf5_subchunk_zlib");
2506        let mut writer = Hdf5Writer::new();
2507        writer.set_chunk_size_auto(false);
2508        writer.set_n_row_chunks(4);
2509        writer.set_n_col_chunks(4);
2510        writer.set_compression_type(COMPRESS_ZLIB);
2511
2512        let mut arr = NDArray::new(
2513            vec![NDDimension::new(8), NDDimension::new(8)],
2514            NDDataType::UInt16,
2515        );
2516        for f in 0..2u16 {
2517            if let NDDataBuffer::U16(ref mut v) = arr.data {
2518                for (i, x) in v.iter_mut().enumerate() {
2519                    *x = f * 100 + i as u16;
2520                }
2521            }
2522            if f == 0 {
2523                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2524            }
2525            writer.write_file(&arr).unwrap();
2526        }
2527        writer.close_file().unwrap();
2528
2529        let h5 = H5File::open(&path).unwrap();
2530        let ds = h5.dataset("data").unwrap();
2531        assert_eq!(ds.shape(), vec![2, 8, 8]);
2532        assert_eq!(ds.chunk_dims(), Some(vec![1, 4, 4]));
2533        let raw: Vec<u16> = ds.read_raw().unwrap();
2534        for f in 0..2u16 {
2535            for i in 0..64usize {
2536                assert_eq!(raw[f as usize * 64 + i], f * 100 + i as u16);
2537            }
2538        }
2539
2540        std::fs::remove_file(&path).ok();
2541    }
2542
2543    #[test]
2544    fn test_non_dividing_chunk_is_honored_and_extent_trimmed() {
2545        // A chunk size that does not divide the frame is honored as-is;
2546        // write_chunk_at rounds the extent up, and close_file's set_extent
2547        // trims the dataset shape back to the exact [N, Y, X].
2548        let path = temp_path("hdf5_subchunk_nd");
2549        let mut writer = Hdf5Writer::new();
2550        writer.set_chunk_size_auto(false); // honor explicit chunk sizes
2551        writer.set_n_row_chunks(3); // Y = 8, 8 % 3 != 0 → honored
2552        writer.set_n_col_chunks(4); // X = 8 → honored
2553
2554        let mut arr = NDArray::new(
2555            vec![NDDimension::new(8), NDDimension::new(8)],
2556            NDDataType::UInt16,
2557        );
2558        if let NDDataBuffer::U16(ref mut v) = arr.data {
2559            for (i, x) in v.iter_mut().enumerate() {
2560                *x = i as u16;
2561            }
2562        }
2563        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2564        writer.write_file(&arr).unwrap();
2565        writer.write_file(&arr).unwrap();
2566        writer.close_file().unwrap();
2567
2568        let h5 = H5File::open(&path).unwrap();
2569        let ds = h5.dataset("data").unwrap();
2570        assert_eq!(ds.shape(), vec![2, 8, 8], "extent trimmed, not padded");
2571        assert_eq!(ds.chunk_dims(), Some(vec![1, 3, 4]));
2572        let raw: Vec<u16> = ds.read_raw().unwrap();
2573        assert_eq!(raw.len(), 2 * 64);
2574        for i in 0..64usize {
2575            assert_eq!(raw[i], i as u16);
2576            assert_eq!(raw[64 + i], i as u16);
2577        }
2578
2579        std::fs::remove_file(&path).ok();
2580    }
2581
2582    #[test]
2583    fn test_n_frames_chunks_band() {
2584        // HDF5_nFramesChunks groups frames into a multi-frame chunk band; the
2585        // logical frame count stays exact even when the last band is partial.
2586        let path = temp_path("hdf5_framechunks");
2587        let mut writer = Hdf5Writer::new();
2588        writer.set_chunk_size_auto(false);
2589        writer.set_n_frames_chunks(2); // 2 frames per chunk band
2590
2591        let mut arr = NDArray::new(
2592            vec![NDDimension::new(4), NDDimension::new(4)],
2593            NDDataType::UInt16,
2594        );
2595        // 5 frames → bands [0,1], [2,3], [4] (partial).
2596        for f in 0..5u16 {
2597            if let NDDataBuffer::U16(ref mut v) = arr.data {
2598                for (i, x) in v.iter_mut().enumerate() {
2599                    *x = f * 1000 + i as u16;
2600                }
2601            }
2602            if f == 0 {
2603                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2604            }
2605            writer.write_file(&arr).unwrap();
2606        }
2607        writer.close_file().unwrap();
2608
2609        let h5 = H5File::open(&path).unwrap();
2610        let ds = h5.dataset("data").unwrap();
2611        assert_eq!(ds.shape(), vec![5, 4, 4], "exact frame count, no padding");
2612        assert_eq!(ds.chunk_dims(), Some(vec![2, 4, 4]));
2613        let raw: Vec<u16> = ds.read_raw().unwrap();
2614        for f in 0..5u16 {
2615            for i in 0..16usize {
2616                assert_eq!(raw[f as usize * 16 + i], f * 1000 + i as u16);
2617            }
2618        }
2619
2620        std::fs::remove_file(&path).ok();
2621    }
2622
2623    #[test]
2624    fn test_frames_chunks_with_sub_frame_tiles() {
2625        // Full chunk geometry: nFramesChunks AND sub-frame row/col tiling at
2626        // once — exercises the complete flush_band [fc, rc, cc] tile grid
2627        // with a partial final band.
2628        let path = temp_path("hdf5_full_chunk");
2629        let mut writer = Hdf5Writer::new();
2630        writer.set_chunk_size_auto(false);
2631        writer.set_n_frames_chunks(2); // 2 frames per band
2632        writer.set_n_row_chunks(4); // Y = 8 → 2 row tiles
2633        writer.set_n_col_chunks(4); // X = 8 → 2 col tiles
2634
2635        let mut arr = NDArray::new(
2636            vec![NDDimension::new(8), NDDimension::new(8)],
2637            NDDataType::UInt16,
2638        );
2639        // 3 frames → band [0,1] full, band [2] partial; 2x2 tiles each.
2640        for f in 0..3u16 {
2641            if let NDDataBuffer::U16(ref mut v) = arr.data {
2642                for (i, x) in v.iter_mut().enumerate() {
2643                    *x = f * 1000 + i as u16;
2644                }
2645            }
2646            if f == 0 {
2647                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2648            }
2649            writer.write_file(&arr).unwrap();
2650        }
2651        writer.close_file().unwrap();
2652
2653        let h5 = H5File::open(&path).unwrap();
2654        let ds = h5.dataset("data").unwrap();
2655        assert_eq!(ds.shape(), vec![3, 8, 8], "exact frame count");
2656        assert_eq!(ds.chunk_dims(), Some(vec![2, 4, 4]));
2657        let raw: Vec<u16> = ds.read_raw().unwrap();
2658        assert_eq!(raw.len(), 3 * 64);
2659        for f in 0..3u16 {
2660            for i in 0..64usize {
2661                assert_eq!(
2662                    raw[f as usize * 64 + i],
2663                    f * 1000 + i as u16,
2664                    "frame {} elem {}",
2665                    f,
2666                    i
2667                );
2668            }
2669        }
2670
2671        std::fs::remove_file(&path).ok();
2672    }
2673
2674    #[test]
2675    fn test_attribute_datasets() {
2676        let path = temp_path("hdf5_attr_ds");
2677        let mut writer = Hdf5Writer::new();
2678
2679        let mk = |exposure: f64, count: i32| {
2680            let mut arr = NDArray::new(vec![NDDimension::new(4)], NDDataType::UInt8);
2681            arr.attributes.add(NDAttribute::new_static(
2682                "exposure",
2683                "",
2684                NDAttrSource::Driver,
2685                NDAttrValue::Float64(exposure),
2686            ));
2687            arr.attributes.add(NDAttribute::new_static(
2688                "count",
2689                "",
2690                NDAttrSource::Driver,
2691                NDAttrValue::Int32(count),
2692            ));
2693            arr
2694        };
2695
2696        let a0 = mk(0.5, 10);
2697        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
2698        writer.write_file(&a0).unwrap();
2699        writer.write_file(&mk(0.75, 20)).unwrap();
2700        writer.write_file(&mk(1.25, 30)).unwrap();
2701        writer.close_file().unwrap();
2702
2703        let h5 = H5File::open(&path).unwrap();
2704        // One HDF5 dataset per NDAttribute, under NDAttributes/, [nframes].
2705        let exp = h5.dataset("NDAttributes/exposure").unwrap();
2706        assert_eq!(exp.shape(), vec![3]);
2707        let exp_vals: Vec<f64> = exp.read_raw().unwrap();
2708        assert_eq!(exp_vals, vec![0.5, 0.75, 1.25]);
2709
2710        let cnt = h5.dataset("NDAttributes/count").unwrap();
2711        assert_eq!(cnt.shape(), vec![3]);
2712        // Numeric type preserved: i32, not stringified.
2713        let cnt_vals: Vec<i32> = cnt.read_raw().unwrap();
2714        assert_eq!(cnt_vals, vec![10, 20, 30]);
2715
2716        std::fs::remove_file(&path).ok();
2717    }
2718
2719    #[test]
2720    fn test_fill_value_recorded_on_dataset() {
2721        // The configured HDF5_fillValue reaches the DCPL via rust-hdf5 0.2.15's
2722        // `DatasetBuilder::fill_value`; it is also mirrored as a dataset
2723        // attribute for tooling. Verify both the attribute and that an
2724        // unwritten region of a fill-valued dataset reads back as `fill`.
2725        let path = temp_path("hdf5_fill");
2726        let mut writer = Hdf5Writer::new();
2727        writer.set_fill_value(7.5);
2728
2729        let arr = NDArray::new(
2730            vec![NDDimension::new(4), NDDimension::new(4)],
2731            NDDataType::UInt16,
2732        );
2733        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
2734        writer.write_file(&arr).unwrap();
2735        writer.close_file().unwrap();
2736
2737        let h5 = H5File::open(&path).unwrap();
2738        let ds = h5.dataset("data").unwrap();
2739        let fv: f64 = ds.attr("HDF5_fillValue").unwrap().read_numeric().unwrap();
2740        assert_eq!(fv, 7.5);
2741        std::fs::remove_file(&path).ok();
2742
2743        // Direct DCPL check: a fixed-shape dataset created with fill_value and
2744        // never written reads back the fill value, not zero.
2745        let path2 = temp_path("hdf5_fill_dcpl");
2746        {
2747            let f = H5File::create(&path2).unwrap();
2748            let _ = f
2749                .new_dataset::<i32>()
2750                .shape(&[8][..])
2751                .fill_value(42i32)
2752                .create("unwritten")
2753                .unwrap();
2754        }
2755        let h5b = H5File::open(&path2).unwrap();
2756        let vals: Vec<i32> = h5b.dataset("unwritten").unwrap().read_raw().unwrap();
2757        assert_eq!(vals, vec![42i32; 8]);
2758        std::fs::remove_file(&path2).ok();
2759    }
2760
2761    #[test]
2762    fn test_performance_dataset() {
2763        let path = temp_path("hdf5_perf");
2764        let mut writer = Hdf5Writer::new();
2765        writer.set_store_performance(true);
2766
2767        let arr = NDArray::new(
2768            vec![NDDimension::new(8), NDDimension::new(8)],
2769            NDDataType::UInt16,
2770        );
2771        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2772        writer.write_file(&arr).unwrap();
2773        writer.write_file(&arr).unwrap();
2774        writer.close_file().unwrap();
2775
2776        let h5 = H5File::open(&path).unwrap();
2777        let ts = h5.dataset("performance/timestamp").unwrap();
2778        assert_eq!(ts.shape(), vec![2, 5]);
2779        let vals: Vec<f64> = ts.read_raw().unwrap();
2780        assert_eq!(vals.len(), 10);
2781
2782        std::fs::remove_file(&path).ok();
2783    }
2784
2785    #[test]
2786    fn test_roundtrip_all_types() {
2787        macro_rules! roundtrip {
2788            ($name:expr, $dt:expr, $variant:ident, $ty:ty, $vals:expr) => {{
2789                let path = temp_path($name);
2790                let mut writer = Hdf5Writer::new();
2791                let mut arr = NDArray::new(vec![NDDimension::new(4)], $dt);
2792                if let NDDataBuffer::$variant(ref mut v) = arr.data {
2793                    let src: Vec<$ty> = $vals;
2794                    v.copy_from_slice(&src);
2795                }
2796                writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
2797                writer.write_file(&arr).unwrap();
2798                writer.close_file().unwrap();
2799
2800                let mut reader = Hdf5Writer::new();
2801                reader.current_path = Some(path.clone());
2802                let r = reader.read_file().unwrap();
2803                assert_eq!(r.data.data_type(), $dt, "type for {}", $name);
2804                if let NDDataBuffer::$variant(ref v) = r.data {
2805                    let src: Vec<$ty> = $vals;
2806                    assert_eq!(v, &src, "values for {}", $name);
2807                } else {
2808                    panic!("wrong buffer variant for {}", $name);
2809                }
2810                std::fs::remove_file(&path).ok();
2811            }};
2812        }
2813
2814        roundtrip!("rt_i8", NDDataType::Int8, I8, i8, vec![-1, 0, 1, 127]);
2815        roundtrip!("rt_u8", NDDataType::UInt8, U8, u8, vec![0, 1, 200, 255]);
2816        roundtrip!(
2817            "rt_i16",
2818            NDDataType::Int16,
2819            I16,
2820            i16,
2821            vec![-32768, -1, 1, 32767]
2822        );
2823        roundtrip!(
2824            "rt_u16",
2825            NDDataType::UInt16,
2826            U16,
2827            u16,
2828            vec![0, 1, 40000, 65535]
2829        );
2830        roundtrip!(
2831            "rt_i32",
2832            NDDataType::Int32,
2833            I32,
2834            i32,
2835            vec![i32::MIN, -1, 1, i32::MAX]
2836        );
2837        roundtrip!(
2838            "rt_u32",
2839            NDDataType::UInt32,
2840            U32,
2841            u32,
2842            vec![0, 1, 3_000_000_000, u32::MAX]
2843        );
2844        roundtrip!(
2845            "rt_i64",
2846            NDDataType::Int64,
2847            I64,
2848            i64,
2849            vec![i64::MIN, -1, 1, i64::MAX]
2850        );
2851        roundtrip!(
2852            "rt_u64",
2853            NDDataType::UInt64,
2854            U64,
2855            u64,
2856            vec![0, 1, 9_000_000_000, u64::MAX]
2857        );
2858        roundtrip!(
2859            "rt_f32",
2860            NDDataType::Float32,
2861            F32,
2862            f32,
2863            vec![-1.5, 0.0, 2.25, 3.75]
2864        );
2865        roundtrip!(
2866            "rt_f64",
2867            NDDataType::Float64,
2868            F64,
2869            f64,
2870            vec![-1.5, 0.0, 2.25, 3.75]
2871        );
2872    }
2873
2874    #[test]
2875    fn test_deflate_compressed_write() {
2876        let path = temp_path("hdf5_deflate");
2877        let mut writer = Hdf5Writer::new();
2878        writer.set_compression_type(COMPRESS_ZLIB);
2879        writer.set_z_compress_level(6);
2880
2881        let mut arr = NDArray::new(
2882            vec![NDDimension::new(64), NDDimension::new(64)],
2883            NDDataType::UInt16,
2884        );
2885        if let NDDataBuffer::U16(ref mut v) = arr.data {
2886            for i in 0..v.len() {
2887                v[i] = (i % 256) as u16;
2888            }
2889        }
2890
2891        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
2892        writer.write_file(&arr).unwrap();
2893        writer.close_file().unwrap();
2894
2895        let file_size = std::fs::metadata(&path).unwrap().len();
2896        assert!(
2897            file_size < 8192,
2898            "compressed file should be smaller than raw data"
2899        );
2900
2901        let h5file = H5File::open(&path).unwrap();
2902        let ds = h5file.dataset("data").unwrap();
2903        let data: Vec<u16> = ds.read_raw().unwrap();
2904        assert_eq!(data.len(), 64 * 64);
2905        assert_eq!(data[0], 0);
2906        assert_eq!(data[255], 255);
2907        assert_eq!(data[256], 0);
2908
2909        std::fs::remove_file(&path).ok();
2910    }
2911
2912    #[test]
2913    fn test_lz4_compressed_write() {
2914        let path = temp_path("hdf5_lz4");
2915        let mut writer = Hdf5Writer::new();
2916        writer.set_compression_type(COMPRESS_LZ4);
2917
2918        let mut arr = NDArray::new(
2919            vec![NDDimension::new(32), NDDimension::new(32)],
2920            NDDataType::UInt8,
2921        );
2922        if let NDDataBuffer::U8(ref mut v) = arr.data {
2923            for i in 0..v.len() {
2924                v[i] = (i % 4) as u8;
2925            }
2926        }
2927
2928        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
2929        writer.write_file(&arr).unwrap();
2930        writer.close_file().unwrap();
2931
2932        let h5file = H5File::open(&path).unwrap();
2933        let ds = h5file.dataset("data").unwrap();
2934        let data: Vec<u8> = ds.read_raw().unwrap();
2935        assert_eq!(data.len(), 32 * 32);
2936        assert_eq!(data[0], 0);
2937        assert_eq!(data[3], 3);
2938
2939        std::fs::remove_file(&path).ok();
2940    }
2941
2942    #[test]
2943    fn test_bitshuffle_compressed_write() {
2944        let path = temp_path("hdf5_bshuf");
2945        let mut writer = Hdf5Writer::new();
2946        writer.set_compression_type(COMPRESS_BSHUF);
2947
2948        let mut arr = NDArray::new(
2949            vec![NDDimension::new(64), NDDimension::new(64)],
2950            NDDataType::UInt16,
2951        );
2952        if let NDDataBuffer::U16(ref mut v) = arr.data {
2953            for i in 0..v.len() {
2954                v[i] = (i % 8) as u16;
2955            }
2956        }
2957
2958        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
2959        writer.write_file(&arr).unwrap();
2960        writer.close_file().unwrap();
2961
2962        let h5file = H5File::open(&path).unwrap();
2963        let ds = h5file.dataset("data").unwrap();
2964        let data: Vec<u16> = ds.read_raw().unwrap();
2965        assert_eq!(data.len(), 64 * 64);
2966        assert_eq!(data[0], 0);
2967        assert_eq!(data[9], 1);
2968
2969        std::fs::remove_file(&path).ok();
2970    }
2971
2972    #[test]
2973    fn test_chunk_geometry_recorded() {
2974        // Requested row/col chunk geometry is recorded as dataset attributes
2975        // (the on-disk chunk is one frame per chunk — crate limitation).
2976        let path = temp_path("hdf5_chunkgeom");
2977        let mut writer = Hdf5Writer::new();
2978        writer.set_chunk_size_auto(false);
2979        writer.set_n_row_chunks(4);
2980        writer.set_n_col_chunks(2);
2981        writer.set_n_frames_chunks(3);
2982
2983        let mut arr = NDArray::new(
2984            vec![NDDimension::new(8), NDDimension::new(8)],
2985            NDDataType::UInt16,
2986        );
2987        if let NDDataBuffer::U16(ref mut v) = arr.data {
2988            for i in 0..v.len() {
2989                v[i] = i as u16;
2990            }
2991        }
2992
2993        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
2994        writer.write_file(&arr).unwrap();
2995        writer.write_file(&arr).unwrap();
2996        writer.close_file().unwrap();
2997
2998        let h5 = H5File::open(&path).unwrap();
2999        let ds = h5.dataset("data").unwrap();
3000        assert_eq!(ds.shape(), vec![2, 8, 8]);
3001        // Data still round-trips correctly through the per-frame chunks.
3002        let data: Vec<u16> = ds.read_raw().unwrap();
3003        assert_eq!(data.len(), 2 * 64);
3004        for i in 0..64usize {
3005            assert_eq!(data[i], i as u16, "frame0 element {}", i);
3006            assert_eq!(data[64 + i], i as u16, "frame1 element {}", i);
3007        }
3008        // Requested geometry preserved as attributes.
3009        assert_eq!(
3010            ds.attr("HDF5_nRowChunks")
3011                .unwrap()
3012                .read_numeric::<i32>()
3013                .unwrap(),
3014            4
3015        );
3016        assert_eq!(
3017            ds.attr("HDF5_nColChunks")
3018                .unwrap()
3019                .read_numeric::<i32>()
3020                .unwrap(),
3021            2
3022        );
3023        assert_eq!(
3024            ds.attr("HDF5_nFramesChunks")
3025                .unwrap()
3026                .read_numeric::<i32>()
3027                .unwrap(),
3028            3
3029        );
3030
3031        std::fs::remove_file(&path).ok();
3032    }
3033
3034    #[test]
3035    fn test_extra_dimensions_layout() {
3036        // HDF5_nExtraDims=2 with sizes [2,3] => 6-frame [6,Y,X] dataset with
3037        // the extra-dim sizes/names recorded as attributes (the flat leading
3038        // axis reshapes to the intended [2,3,Y,X]).
3039        let path = temp_path("hdf5_extradims");
3040        let mut writer = Hdf5Writer::new();
3041        writer.set_n_extra_dims(2);
3042        writer.set_extra_dim_size(0, 2);
3043        writer.set_extra_dim_size(1, 3);
3044        writer.set_extra_dim_name(0, "scanY");
3045        writer.set_extra_dim_name(1, "scanX");
3046
3047        let mut arr = NDArray::new(
3048            vec![NDDimension::new(4), NDDimension::new(4)],
3049            NDDataType::UInt16,
3050        );
3051        for f in 0..6u16 {
3052            if let NDDataBuffer::U16(ref mut v) = arr.data {
3053                for x in v.iter_mut() {
3054                    *x = f;
3055                }
3056            }
3057            if f == 0 {
3058                writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3059            }
3060            writer.write_file(&arr).unwrap();
3061        }
3062        writer.close_file().unwrap();
3063
3064        let h5 = H5File::open(&path).unwrap();
3065        let ds = h5.dataset("data").unwrap();
3066        // Flat leading axis = product(2,3) = 6.
3067        assert_eq!(ds.shape(), vec![6, 4, 4]);
3068        let data: Vec<u16> = ds.read_raw().unwrap();
3069        assert_eq!(data.len(), 6 * 16);
3070        for f in 0..6usize {
3071            for i in 0..16usize {
3072                assert_eq!(data[f * 16 + i], f as u16, "frame {} elem {}", f, i);
3073            }
3074        }
3075        // Extra-dim layout recoverable from attributes.
3076        assert_eq!(
3077            ds.attr("HDF5_nExtraDims")
3078                .unwrap()
3079                .read_numeric::<i32>()
3080                .unwrap(),
3081            2
3082        );
3083        assert_eq!(
3084            ds.attr("HDF5_extraDimSize0")
3085                .unwrap()
3086                .read_numeric::<i32>()
3087                .unwrap(),
3088            2
3089        );
3090        assert_eq!(
3091            ds.attr("HDF5_extraDimSize1")
3092                .unwrap()
3093                .read_numeric::<i32>()
3094                .unwrap(),
3095            3
3096        );
3097        assert_eq!(
3098            ds.attr("HDF5_extraDimName0")
3099                .unwrap()
3100                .read_string()
3101                .unwrap(),
3102            "scanY"
3103        );
3104
3105        std::fs::remove_file(&path).ok();
3106    }
3107
3108    #[test]
3109    fn test_swmr_streaming() {
3110        let path = temp_path("hdf5_swmr");
3111        let mut writer = Hdf5Writer::new();
3112        writer.set_swmr_mode(true);
3113        writer.set_flush_nth_frame(2);
3114
3115        let arr = NDArray::new(
3116            vec![NDDimension::new(8), NDDimension::new(8)],
3117            NDDataType::Float32,
3118        );
3119
3120        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3121        writer.write_file(&arr).unwrap();
3122        writer.write_file(&arr).unwrap(); // should trigger flush
3123        writer.write_file(&arr).unwrap();
3124        writer.close_file().unwrap();
3125
3126        assert_eq!(writer.frame_count(), 3);
3127
3128        // Read back via SwmrFileReader
3129        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
3130        let shape = reader.dataset_shape("data").unwrap();
3131        assert_eq!(shape[0], 3); // 3 frames
3132        assert_eq!(shape[1], 8);
3133        assert_eq!(shape[2], 8);
3134
3135        let data: Vec<f32> = reader.read_dataset("data").unwrap();
3136        assert_eq!(data.len(), 3 * 8 * 8);
3137
3138        std::fs::remove_file(&path).ok();
3139    }
3140
3141    #[test]
3142    fn test_swmr_compression_is_applied() {
3143        // rust-hdf5 0.2.15 exposes a filtered SWMR dataset constructor, so
3144        // SWMR + compression produces a genuinely compressed file — the
3145        // compression is NOT dropped, and the data round-trips.
3146        let path = temp_path("hdf5_swmr_comp");
3147        let mut writer = Hdf5Writer::new();
3148        writer.set_swmr_mode(true);
3149        writer.set_compression_type(COMPRESS_ZLIB);
3150
3151        let arr = NDArray::new(
3152            vec![NDDimension::new(8), NDDimension::new(8)],
3153            NDDataType::UInt16,
3154        );
3155        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3156        assert!(
3157            !writer.swmr_compression_dropped(),
3158            "SWMR+ZLIB must apply compression, not drop it"
3159        );
3160        writer.write_file(&arr).unwrap();
3161        writer.write_file(&arr).unwrap();
3162        writer.close_file().unwrap();
3163
3164        // The compressed SWMR dataset round-trips.
3165        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
3166        let shape = reader.dataset_shape("data").unwrap();
3167        assert_eq!(shape, vec![2, 8, 8]);
3168        let data: Vec<u16> = reader.read_dataset("data").unwrap();
3169        assert_eq!(data.len(), 2 * 8 * 8);
3170
3171        std::fs::remove_file(&path).ok();
3172    }
3173
3174    #[test]
3175    fn test_layout_xml_param() {
3176        // Valid and invalid layout XML drive layout_valid / layout_error.
3177        let mut writer = Hdf5Writer::new();
3178        let dir = std::env::temp_dir();
3179        let good = dir.join("adcore_layout_good.xml");
3180        std::fs::write(
3181            &good,
3182            r#"<hdf5_layout><group name="entry"><dataset name="data" source="detector" det_default="true"/></group></hdf5_layout>"#,
3183        )
3184        .unwrap();
3185        assert!(writer.set_layout_filename(good.to_str().unwrap()));
3186        assert!(writer.layout_valid);
3187        assert!(writer.layout_error.is_empty());
3188
3189        let bad = dir.join("adcore_layout_bad.xml");
3190        std::fs::write(&bad, r#"<not_a_layout/>"#).unwrap();
3191        assert!(!writer.set_layout_filename(bad.to_str().unwrap()));
3192        assert!(!writer.layout_valid);
3193        assert!(!writer.layout_error.is_empty());
3194
3195        std::fs::remove_file(&good).ok();
3196        std::fs::remove_file(&bad).ok();
3197    }
3198
3199    #[test]
3200    fn test_layout_xml_places_dataset_in_nested_tree() {
3201        // A valid layout XML must place the image dataset at the layout's
3202        // det_default path (C ADCore /entry/instrument/detector/data),
3203        // NDAttributes under the ndattr_default group, and the performance
3204        // dataset under the group holding the `timestamp` dataset — NOT flat
3205        // at the file root.
3206        let dir = std::env::temp_dir();
3207        let layout = dir.join("adcore_layout_nested.xml");
3208        std::fs::write(
3209            &layout,
3210            r#"<hdf5_layout>
3211              <group name="entry">
3212                <group name="instrument">
3213                  <group name="detector">
3214                    <dataset name="data" source="detector" det_default="true">
3215                      <attribute name="signal" source="constant" value="1" type="int"/>
3216                    </dataset>
3217                  </group>
3218                  <group name="NDAttributes" ndattr_default="true"/>
3219                  <group name="performance">
3220                    <dataset name="timestamp"/>
3221                  </group>
3222                </group>
3223              </group>
3224            </hdf5_layout>"#,
3225        )
3226        .unwrap();
3227
3228        let path = temp_path("hdf5_layout_nested");
3229        let mut writer = Hdf5Writer::new();
3230        writer.set_store_performance(true);
3231        assert!(
3232            writer.set_layout_filename(layout.to_str().unwrap()),
3233            "layout XML must parse: {}",
3234            writer.layout_error
3235        );
3236
3237        let mk = |fill: f64| {
3238            let mut arr = NDArray::new(
3239                vec![NDDimension::new(4), NDDimension::new(4)],
3240                NDDataType::UInt16,
3241            );
3242            arr.attributes.add(NDAttribute::new_static(
3243                "exposure",
3244                "",
3245                NDAttrSource::Driver,
3246                NDAttrValue::Float64(fill),
3247            ));
3248            arr
3249        };
3250
3251        let a0 = mk(0.5);
3252        writer.open_file(&path, NDFileMode::Stream, &a0).unwrap();
3253        writer.write_file(&a0).unwrap();
3254        writer.write_file(&mk(0.75)).unwrap();
3255        writer.close_file().unwrap();
3256
3257        let h5 = H5File::open(&path).unwrap();
3258        let names = h5.dataset_names();
3259        // Image dataset at the nested layout path, NOT flat `data`.
3260        assert!(
3261            names.contains(&"entry/instrument/detector/data".to_string()),
3262            "image dataset must be at the nested layout path; got {:?}",
3263            names
3264        );
3265        assert!(
3266            !names.contains(&"data".to_string()),
3267            "must not also write a flat-root `data` dataset"
3268        );
3269        let img = h5.dataset("entry/instrument/detector/data").unwrap();
3270        assert_eq!(img.shape(), vec![2, 4, 4]);
3271        // Layout constant attribute materialised.
3272        assert_eq!(
3273            img.attr("signal").unwrap().read_numeric::<i64>().unwrap(),
3274            1
3275        );
3276        // NDAttribute dataset under the ndattr_default group.
3277        assert!(
3278            names.contains(&"entry/instrument/NDAttributes/exposure".to_string()),
3279            "NDAttribute dataset must be under the layout ndattr group; got {:?}",
3280            names
3281        );
3282        // Performance dataset under the layout's performance group.
3283        assert!(
3284            names.contains(&"entry/instrument/performance/timestamp".to_string()),
3285            "performance dataset must be under the layout group; got {:?}",
3286            names
3287        );
3288
3289        // Read-back resolves the nested dataset path.
3290        drop(h5);
3291        let mut reader = Hdf5Writer::new();
3292        assert!(reader.set_layout_filename(layout.to_str().unwrap()));
3293        reader.current_path = Some(path.clone());
3294        let read_arr = reader.read_file().unwrap();
3295        assert_eq!(read_arr.dims.len(), 3);
3296
3297        std::fs::remove_file(&path).ok();
3298        std::fs::remove_file(&layout).ok();
3299    }
3300
3301    #[test]
3302    fn test_layout_hardlink_is_materialised() {
3303        // Regression for BUG 2: a `<hardlink>` declared in the layout XML must
3304        // produce a real HDF5 hard link in the written file. C ADCore
3305        // `NDFileHDF5::createHardLinks` walks the layout and calls
3306        // `H5Lcreate_hard`; without that, files written from a layout with a
3307        // `<hardlink>` silently lack the link.
3308        let dir = std::env::temp_dir();
3309        let layout = dir.join("adcore_layout_hardlink.xml");
3310        std::fs::write(
3311            &layout,
3312            r#"<hdf5_layout>
3313              <group name="entry">
3314                <group name="data">
3315                  <dataset name="data" source="detector" det_default="true"/>
3316                  <hardlink name="data_alias" target="/entry/data/data"/>
3317                </group>
3318              </group>
3319            </hdf5_layout>"#,
3320        )
3321        .unwrap();
3322
3323        let path = temp_path("hdf5_layout_hardlink");
3324        let mut writer = Hdf5Writer::new();
3325        assert!(
3326            writer.set_layout_filename(layout.to_str().unwrap()),
3327            "layout XML must parse: {}",
3328            writer.layout_error
3329        );
3330
3331        let arr = NDArray::new(
3332            vec![NDDimension::new(4), NDDimension::new(4)],
3333            NDDataType::UInt16,
3334        );
3335        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3336        writer.write_file(&arr).unwrap();
3337        writer.close_file().unwrap();
3338
3339        let h5 = H5File::open(&path).unwrap();
3340        let names = h5.dataset_names();
3341        // The primary dataset at its layout path.
3342        assert!(
3343            names.contains(&"entry/data/data".to_string()),
3344            "image dataset must exist at the layout path; got {:?}",
3345            names
3346        );
3347        // The hard link is an additional name resolving to the same object.
3348        assert!(
3349            names.contains(&"entry/data/data_alias".to_string()),
3350            "layout <hardlink> must be materialised as a hard link; got {:?}",
3351            names
3352        );
3353        // The link shares the target object: same shape, readable as a dataset.
3354        let alias = h5.dataset("entry/data/data_alias").unwrap();
3355        let orig = h5.dataset("entry/data/data").unwrap();
3356        assert_eq!(alias.shape(), orig.shape());
3357
3358        drop(h5);
3359        std::fs::remove_file(&path).ok();
3360        std::fs::remove_file(&layout).ok();
3361    }
3362
3363    #[test]
3364    fn test_swmr_layout_hardlink_is_materialised() {
3365        // A `<hardlink>` declared in the layout XML must also be materialised
3366        // for SWMR-mode files. C ADCore `NDFileHDF5.cpp:320`-`326` calls
3367        // `createHardLinks` before `startSWMR()`, so the link is committed by
3368        // `start_swmr()` and visible to SWMR readers for the whole streaming
3369        // window. The rust-hdf5 0.2.17 `SwmrFileWriter::create_hard_link` API
3370        // is called from `open_swmr` before `start_swmr()` — no close-path
3371        // re-open pass.
3372        //
3373        // SWMR mode now places the image dataset at the layout's nested
3374        // `det_default` path (`/entry/data/data`), exactly like standard mode;
3375        // the layout hardlink targets that nested path.
3376        let dir = std::env::temp_dir();
3377        let layout = dir.join("adcore_swmr_layout_hardlink.xml");
3378        std::fs::write(
3379            &layout,
3380            r#"<hdf5_layout>
3381              <group name="entry">
3382                <group name="data">
3383                  <dataset name="data" source="detector" det_default="true"/>
3384                  <hardlink name="data_alias" target="/entry/data/data"/>
3385                </group>
3386              </group>
3387            </hdf5_layout>"#,
3388        )
3389        .unwrap();
3390
3391        let path = temp_path("hdf5_swmr_layout_hardlink");
3392        let mut writer = Hdf5Writer::new();
3393        writer.set_swmr_mode(true);
3394        assert!(
3395            writer.set_layout_filename(layout.to_str().unwrap()),
3396            "layout XML must parse: {}",
3397            writer.layout_error
3398        );
3399
3400        let mut arr = NDArray::new(
3401            vec![NDDimension::new(4), NDDimension::new(4)],
3402            NDDataType::UInt16,
3403        );
3404        if let NDDataBuffer::U16(ref mut v) = arr.data {
3405            for (i, x) in v.iter_mut().enumerate() {
3406                *x = i as u16;
3407            }
3408        }
3409        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3410        assert!(
3411            writer.is_swmr_active(),
3412            "writer must be in SWMR mode for this test"
3413        );
3414        writer.write_file(&arr).unwrap();
3415        writer.write_file(&arr).unwrap();
3416        writer.close_file().unwrap();
3417
3418        let h5 = H5File::open(&path).unwrap();
3419        let names = h5.dataset_names();
3420        // The primary SWMR dataset at its nested layout path.
3421        assert!(
3422            names.contains(&"entry/data/data".to_string()),
3423            "SWMR image dataset must exist at the nested layout path; got {:?}",
3424            names
3425        );
3426        // The hard link materialised under the layout group.
3427        assert!(
3428            names.contains(&"entry/data/data_alias".to_string()),
3429            "SWMR layout <hardlink> must be materialised as a hard link; got {:?}",
3430            names
3431        );
3432        // The link shares the target object: same shape, readable as a dataset.
3433        let alias = h5.dataset("entry/data/data_alias").unwrap();
3434        let orig = h5.dataset("entry/data/data").unwrap();
3435        assert_eq!(alias.shape(), orig.shape());
3436        assert_eq!(orig.shape(), vec![2, 4, 4]);
3437
3438        drop(h5);
3439        std::fs::remove_file(&path).ok();
3440        std::fs::remove_file(&layout).ok();
3441    }
3442
3443    #[test]
3444    fn test_swmr_layout_nested_dataset_placement() {
3445        // SWMR mode must place the image dataset at the layout's nested
3446        // `det_default` path — mirroring C `NDFileHDF5` createTree
3447        // (`NDFileHDF5.cpp:638`) which builds the group tree and creates the
3448        // detector dataset inside it. The nested dataset, the layout
3449        // `<hardlink>`, and a constant dataset attribute must all be visible
3450        // to a `SwmrFileReader` reading the file back.
3451        let dir = std::env::temp_dir();
3452        let layout = dir.join("adcore_swmr_layout_nested.xml");
3453        std::fs::write(
3454            &layout,
3455            r#"<hdf5_layout>
3456              <group name="entry">
3457                <group name="instrument">
3458                  <group name="detector">
3459                    <dataset name="data" source="detector" det_default="true">
3460                      <attribute name="signal" source="constant" value="1" type="int"/>
3461                    </dataset>
3462                    <hardlink name="data_alias" target="/entry/instrument/detector/data"/>
3463                  </group>
3464                </group>
3465                <group name="empty_placeholder"/>
3466              </group>
3467            </hdf5_layout>"#,
3468        )
3469        .unwrap();
3470
3471        let path = temp_path("hdf5_swmr_layout_nested");
3472        let mut writer = Hdf5Writer::new();
3473        writer.set_swmr_mode(true);
3474        assert!(
3475            writer.set_layout_filename(layout.to_str().unwrap()),
3476            "layout XML must parse: {}",
3477            writer.layout_error
3478        );
3479
3480        let mut arr = NDArray::new(
3481            vec![NDDimension::new(4), NDDimension::new(4)],
3482            NDDataType::UInt16,
3483        );
3484        if let NDDataBuffer::U16(ref mut v) = arr.data {
3485            for (i, x) in v.iter_mut().enumerate() {
3486                *x = (i * 3) as u16;
3487            }
3488        }
3489        writer.open_file(&path, NDFileMode::Stream, &arr).unwrap();
3490        assert!(
3491            writer.is_swmr_active(),
3492            "writer must be in SWMR mode for this test"
3493        );
3494        writer.write_file(&arr).unwrap();
3495        writer.write_file(&arr).unwrap();
3496        writer.close_file().unwrap();
3497
3498        // Read back via the SWMR reader — these are the exact paths a live
3499        // reader attaching during the streaming window would resolve.
3500        let mut reader = rust_hdf5::swmr::SwmrFileReader::open(&path).unwrap();
3501        let names = reader.dataset_names();
3502        // Image dataset at the nested layout path, NOT flat `data`.
3503        assert!(
3504            names.contains(&"entry/instrument/detector/data".to_string()),
3505            "SWMR image dataset must live at the nested layout path; got {:?}",
3506            names
3507        );
3508        assert!(
3509            !names.contains(&"data".to_string()),
3510            "SWMR image dataset must NOT remain at the flat root; got {:?}",
3511            names
3512        );
3513        // The empty placeholder group exists.
3514        assert!(
3515            reader.has_group("entry/empty_placeholder"),
3516            "empty layout group must be materialised; groups {:?}",
3517            reader.group_paths()
3518        );
3519        // The layout `<hardlink>` resolves to the nested dataset.
3520        assert!(
3521            names.contains(&"entry/instrument/detector/data_alias".to_string()),
3522            "SWMR layout <hardlink> must resolve to the nested dataset; got {:?}",
3523            names
3524        );
3525        let nested = reader
3526            .dataset_shape("entry/instrument/detector/data")
3527            .unwrap();
3528        let alias = reader
3529            .dataset_shape("entry/instrument/detector/data_alias")
3530            .unwrap();
3531        assert_eq!(nested, vec![2, 4, 4]);
3532        assert_eq!(alias, nested, "hardlink alias must share the target shape");
3533        // The data round-trips through both names.
3534        let via_nested: Vec<u16> = reader
3535            .read_dataset("entry/instrument/detector/data")
3536            .unwrap();
3537        let via_alias: Vec<u16> = reader
3538            .read_dataset("entry/instrument/detector/data_alias")
3539            .unwrap();
3540        assert_eq!(via_nested, via_alias);
3541        assert_eq!(via_nested.len(), 2 * 4 * 4);
3542        // The constant dataset attribute materialised before start_swmr().
3543        assert_eq!(
3544            reader
3545                .dataset_attr_names("entry/instrument/detector/data")
3546                .unwrap(),
3547            vec!["signal".to_string()],
3548        );
3549
3550        drop(reader);
3551        std::fs::remove_file(&path).ok();
3552        std::fs::remove_file(&layout).ok();
3553    }
3554
3555    #[test]
3556    fn test_no_layout_keeps_flat_root_default() {
3557        // Without a layout file the writer keeps the flat-root `data` default.
3558        let path = temp_path("hdf5_flat_default");
3559        let mut writer = Hdf5Writer::new();
3560        let arr = NDArray::new(
3561            vec![NDDimension::new(4), NDDimension::new(4)],
3562            NDDataType::UInt8,
3563        );
3564        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
3565        writer.write_file(&arr).unwrap();
3566        writer.close_file().unwrap();
3567
3568        let h5 = H5File::open(&path).unwrap();
3569        assert!(h5.dataset_names().contains(&"data".to_string()));
3570        std::fs::remove_file(&path).ok();
3571    }
3572}