Skip to main content

nereids_io/
project.rs

1//! Project file save and load for `.nrd.h5` (NEREIDS HDF5 archive).
2//!
3//! The project file captures the full session state so users can persist
4//! and share analysis sessions. This module defines [`ProjectSnapshot`]
5//! (a serialization-friendly subset of the GUI's `AppState`) and the
6//! [`save_project`] and [`load_project`] functions for HDF5 I/O.
7
8use std::path::Path;
9
10use hdf5::types::VarLenUnicode;
11use ndarray::{Array2, Array3};
12
13use nereids_endf::resonance::ResonanceData;
14
15use crate::error::IoError;
16
17/// Current schema version written to `/meta/version`.
18pub const PROJECT_SCHEMA_VERSION: &str = "1.0";
19
20/// Format version written to the `format_version` attribute of the
21/// `/intermediate/detected_dead_pixels` dataset (#646 review R4, P1-1).
22///
23/// The dataset carries the session-scoped *detected* dead/hot mask
24/// component (the declared component lives at
25/// `/intermediate/dead_pixels`, unversioned for backward compatibility).
26/// Readers accept exactly this version; a dataset with a missing or
27/// unrecognized `format_version` is ignored, as is a dataset whose
28/// rank is not 2 (the same shape gate the declared-mask reader
29/// applies) — the restore then falls back to the declared-only
30/// behavior that predates the dataset, which is also the fallback for
31/// old files lacking the dataset entirely.
32pub const DETECTED_DEAD_PIXELS_FORMAT_VERSION: u32 = 1;
33
34/// Serialization-friendly snapshot of the full session state.
35///
36/// All GUI-specific enums are stored as plain strings so this struct
37/// has no dependency on the GUI crate. The GUI handles
38/// `AppState <-> ProjectSnapshot` conversion.
39#[derive(Debug)]
40pub struct ProjectSnapshot {
41    // -- meta --
42    pub schema_version: String,
43    pub created_utc: String,
44    pub software_version: String,
45    /// "spatial" | "single"
46    pub fitting_type: String,
47    /// "events" | "pre_normalized" | "transmission"
48    pub data_type: String,
49
50    // -- config/beamline --
51    pub flight_path_m: f64,
52    pub delay_us: f64,
53    pub proton_charge_sample: f64,
54    pub proton_charge_ob: f64,
55
56    // -- config/isotopes (parallel arrays) --
57    pub isotope_z: Vec<u32>,
58    pub isotope_a: Vec<u32>,
59    pub isotope_symbol: Vec<String>,
60    pub isotope_density: Vec<f64>,
61    pub isotope_enabled: Vec<bool>,
62
63    // -- config/isotope_groups --
64    /// Z for each group.
65    pub isotope_group_z: Vec<u32>,
66    /// Group display names (e.g., "Hf (nat)").
67    pub isotope_group_names: Vec<String>,
68    /// Per-group members as JSON: [{"a":176,"symbol":"Hf-176","ratio":0.0526}, ...]
69    pub isotope_group_members_json: Vec<String>,
70    /// Initial density per group.
71    pub isotope_group_density: Vec<f64>,
72    /// Enabled flag per group.
73    pub isotope_group_enabled: Vec<bool>,
74
75    // -- config/workflow --
76    /// Input mode: "tiff_pair" | "transmission_tiff" | "hdf5_histogram" | "hdf5_event".
77    /// Empty string triggers heuristic fallback for old project files.
78    pub input_mode: String,
79    /// Analysis mode: "full_spatial" | "roi_single" | "spatial_binning".
80    pub analysis_mode: String,
81    /// Binning factor for SpatialBinning analysis mode.
82    pub spatial_binning_factor: Option<u8>,
83
84    // -- config/event_params --
85    pub event_n_bins: u32,
86    pub event_tof_min_us: f64,
87    pub event_tof_max_us: f64,
88    pub event_height: u32,
89    pub event_width: u32,
90
91    // -- config/solver --
92    /// "lm" | "poisson_kl"
93    pub solver_method: String,
94    pub max_iter: u32,
95    pub temperature_k: f64,
96    pub fit_temperature: bool,
97    /// Fit residual energy-scale calibration (TZERO `t₀` + flight-path
98    /// `L_scale`).  `Option` for backwards compatibility — projects
99    /// saved before this field was introduced load as `None` (= false).
100    pub fit_energy_scale: Option<bool>,
101    /// User-specified fit energy range `[min_eV, max_eV]` (SAMMY EMIN/EMAX
102    /// equivalent — INPut-file card set 2, manual Table VI A.1).
103    /// `None` (default) = full grid.  Projects saved before this field
104    /// was introduced load as `None` via the HDF5 reader's missing-
105    /// attribute handling — the bounds are stored as two scalar
106    /// attributes (`fit_energy_range_min_ev` / `fit_energy_range_max_ev`)
107    /// on `/config/solver`, not as a dataset.
108    pub fit_energy_range: Option<(f64, f64)>,
109
110    // -- config/resolution --
111    pub resolution_enabled: bool,
112    /// "gaussian" | "tabulated"
113    pub resolution_kind: String,
114    pub delta_t_us: Option<f64>,
115    pub delta_l_m: Option<f64>,
116    pub tabulated_path: Option<String>,
117
118    // -- config/rois --
119    /// Each ROI: [y_start, y_end, x_start, x_end].
120    pub rois: Vec<[u64; 4]>,
121
122    // -- config/endf --
123    pub endf_library: String,
124
125    // -- data --
126    /// "linked" | "embedded"
127    pub data_mode: String,
128    pub sample_path: Option<String>,
129    pub open_beam_path: Option<String>,
130    pub spectrum_path: Option<String>,
131    pub hdf5_path: Option<String>,
132    /// Path to the HDF5 open beam file (HDF5 modes only).
133    pub hdf5_ob_path: Option<String>,
134    /// "tof_us" | "energy_ev"
135    pub spectrum_unit: String,
136    /// "bin_edges" | "bin_centers"
137    pub spectrum_kind: String,
138    pub rebin_factor: u32,
139    pub rebin_applied: bool,
140
141    // -- data (embedded mode, populated on load) --
142    pub sample_data: Option<Array3<f64>>,
143    pub open_beam_data: Option<Array3<f64>>,
144    pub spectrum_values: Option<Vec<f64>>,
145
146    // -- intermediate (always embedded) --
147    pub normalized: Option<Array3<f64>>,
148    /// D-1: Per-bin transmission uncertainty σ (same shape as normalized).
149    /// Previously missing from the snapshot, causing reloaded projects to
150    /// lose uncertainty information (reconstructed as zeros).
151    pub normalized_uncertainty: Option<Array3<f64>>,
152    pub energies: Option<Vec<f64>>,
153    /// D-20: Dead-pixel mask (true = dead). Same spatial dimensions as the
154    /// transmission data (height × width). `None` when no mask is available.
155    ///
156    /// Since #646 the GUI stores the **declared** mask component only here
157    /// (file-declared or project-declared — `AppState::file_dead_pixels`);
158    /// the detected component travels separately in
159    /// [`ProjectSnapshot::detected_dead_pixels`].  The dataset path
160    /// (`/intermediate/dead_pixels`), dtype, and shape are unchanged, so no
161    /// format marker is needed.  Files written before #646 carried the
162    /// effective mask (declared ∪ detected at save time); the GUI promotes
163    /// those to declared once on load.
164    pub dead_pixels: Option<Array2<bool>>,
165    /// Detected dead/hot mask component active at save time (#646 review
166    /// R4, P1-1).  Same spatial dimensions as the transmission data;
167    /// `None` when no detection had run (or for files predating the
168    /// dataset — restore then falls back to the declared component alone).
169    ///
170    /// Persisted as its own session-scoped dataset
171    /// (`/intermediate/detected_dead_pixels`, u8, with a `format_version`
172    /// attribute — see [`DETECTED_DEAD_PIXELS_FORMAT_VERSION`]) because a
173    /// restored project may carry embedded normalized data WITHOUT the raw
174    /// stacks: normalization (and thus detection) never re-runs there, so a
175    /// declared-only restore would silently refit without the dead/hot
176    /// exclusions that were active at save time.  On restore the GUI
177    /// rebuilds the effective mask as declared ∪ persisted-detected, and —
178    /// when raw stacks ARE present — recomputes detection purely to *warn*
179    /// on drift, never to silently replace the persisted component.
180    pub detected_dead_pixels: Option<Array2<bool>>,
181
182    // -- results (always embedded) --
183    pub density_maps: Option<Vec<Array2<f64>>>,
184    pub uncertainty_maps: Option<Vec<Array2<f64>>>,
185    pub chi_squared_map: Option<Array2<f64>>,
186    pub converged_map: Option<Array2<bool>>,
187    pub temperature_map: Option<Array2<f64>>,
188    pub temperature_uncertainty_map: Option<Array2<f64>>,
189    pub n_converged: Option<usize>,
190    pub n_total: Option<usize>,
191    pub n_failed: Option<usize>,
192    pub result_isotope_labels: Option<Vec<String>>,
193    /// Per-pixel normalization factor (background fitting).
194    pub anorm_map: Option<Array2<f64>>,
195    /// Per-pixel background [A, B, C] maps (background fitting).
196    /// Stored as 3 separate Array2 maps (one per coefficient).
197    pub background_maps: Option<[Array2<f64>; 3]>,
198    /// Global multiplicative-baseline coefficients [b0, b1, b2] from the
199    /// spatial two-stage fit (issue #635).  `None` when no baseline was
200    /// fitted or in per-pixel mode.
201    pub baseline_global: Option<[f64; 3]>,
202    /// Baseline reference energy E_ref (eV) of the centered ln(E/E_ref)
203    /// basis — persisted so the overlay reconstructs B(E) with the exact
204    /// reference the fit used.  `None` when no baseline was fitted.
205    pub baseline_e_ref_ev: Option<f64>,
206    /// Per-pixel baseline coefficient maps [b0, b1, b2] (issue #635,
207    /// per-pixel mode).  Stored like `background_maps`.
208    pub baseline_maps: Option<[Array2<f64>; 3]>,
209
210    // -- results/single_fit (single-pixel fit, optional) --
211    pub single_fit_densities: Option<Vec<f64>>,
212    pub single_fit_uncertainties: Option<Vec<f64>>,
213    pub single_fit_chi_squared: Option<f64>,
214    pub single_fit_temperature: Option<f64>,
215    pub single_fit_temperature_unc: Option<f64>,
216    pub single_fit_converged: Option<bool>,
217    pub single_fit_iterations: Option<usize>,
218    pub single_fit_pixel: Option<(usize, usize)>,
219    pub single_fit_labels: Option<Vec<String>>,
220    /// Fitted normalization factor from single-pixel fit (1.0 default).
221    pub single_fit_anorm: Option<f64>,
222    /// Fitted background [BackA, BackB, BackC] from single-pixel fit.
223    pub single_fit_background: Option<[f64; 3]>,
224    /// Fitted multiplicative-baseline [b0, b1, b2] from single-pixel fit
225    /// (issue #635).  `None` when the baseline was not enabled.
226    pub single_fit_baseline: Option<[f64; 3]>,
227    /// Baseline reference energy E_ref (eV) for the single-pixel fit.
228    pub single_fit_baseline_e_ref_ev: Option<f64>,
229
230    // -- flags --
231    /// True when per-bin uncertainty was estimated (not measured).
232    /// Drives chi-squared warning display in the GUI.
233    pub uncertainty_is_estimated: Option<bool>,
234    /// Whether LM background fitting (Anorm + BackA/B/C) is enabled.
235    pub lm_background_enabled: Option<bool>,
236    /// Whether KL background fitting (b0 + b1/sqrt(E)) is enabled.
237    pub kl_background_enabled: Option<bool>,
238    /// Whether the bounded multiplicative baseline (#635) is enabled.
239    /// This flag SELECTS the fit model (attaches the baseline and holds
240    /// Anorm fixed), so it must round-trip like every sibling solver
241    /// flag — without it a reloaded project displays baseline results
242    /// while a refit silently runs a different model.  `None` for
243    /// pre-#635 files; restore-side defaults to false.
244    pub baseline_enabled: Option<bool>,
245    /// Counts-KL proton-charge ratio `c = Q_s / Q_ob`.
246    /// `None` for project files predating the field; restore-side
247    /// defaults to 1.0.
248    pub kl_c_ratio: Option<f64>,
249    /// Counts-KL Nelder-Mead polish override.  `None` =
250    /// dispatcher auto-disable for multi-pixel; `Some(true/false)` =
251    /// forced.  Outer `Option` distinguishes "not stored" from
252    /// "explicitly None".
253    pub kl_enable_polish_override: Option<Option<bool>>,
254
255    // -- endf_cache --
256    /// (symbol, resonance_data) pairs for offline loading.
257    pub endf_cache: Vec<(String, ResonanceData)>,
258
259    // -- provenance --
260    /// (timestamp, kind, message) triples.
261    pub provenance: Vec<(String, String, String)>,
262}
263
264impl Default for ProjectSnapshot {
265    fn default() -> Self {
266        Self {
267            schema_version: String::new(),
268            created_utc: String::new(),
269            software_version: String::new(),
270            fitting_type: String::new(),
271            data_type: String::new(),
272            flight_path_m: 0.0,
273            delay_us: 0.0,
274            proton_charge_sample: 0.0,
275            proton_charge_ob: 0.0,
276            isotope_z: vec![],
277            isotope_a: vec![],
278            isotope_symbol: vec![],
279            isotope_density: vec![],
280            isotope_enabled: vec![],
281            isotope_group_z: vec![],
282            isotope_group_names: vec![],
283            isotope_group_members_json: vec![],
284            isotope_group_density: vec![],
285            isotope_group_enabled: vec![],
286            input_mode: String::new(),
287            analysis_mode: String::new(),
288            spatial_binning_factor: None,
289            event_n_bins: 0,
290            event_tof_min_us: 0.0,
291            event_tof_max_us: 0.0,
292            event_height: 0,
293            event_width: 0,
294            solver_method: String::new(),
295            max_iter: 0,
296            temperature_k: 0.0,
297            fit_temperature: false,
298            fit_energy_scale: None,
299            fit_energy_range: None,
300            resolution_enabled: false,
301            resolution_kind: String::new(),
302            delta_t_us: None,
303            delta_l_m: None,
304            tabulated_path: None,
305            rois: vec![],
306            endf_library: String::new(),
307            data_mode: String::new(),
308            sample_path: None,
309            open_beam_path: None,
310            spectrum_path: None,
311            hdf5_path: None,
312            hdf5_ob_path: None,
313            spectrum_unit: String::new(),
314            spectrum_kind: String::new(),
315            rebin_factor: 0,
316            rebin_applied: false,
317            sample_data: None,
318            open_beam_data: None,
319            spectrum_values: None,
320            normalized: None,
321            normalized_uncertainty: None,
322            energies: None,
323            dead_pixels: None,
324            detected_dead_pixels: None,
325            density_maps: None,
326            uncertainty_maps: None,
327            chi_squared_map: None,
328            converged_map: None,
329            temperature_map: None,
330            temperature_uncertainty_map: None,
331            n_converged: None,
332            n_total: None,
333            n_failed: None,
334            result_isotope_labels: None,
335            anorm_map: None,
336            background_maps: None,
337            baseline_global: None,
338            baseline_e_ref_ev: None,
339            baseline_maps: None,
340            single_fit_densities: None,
341            single_fit_uncertainties: None,
342            single_fit_chi_squared: None,
343            single_fit_temperature: None,
344            single_fit_temperature_unc: None,
345            single_fit_converged: None,
346            single_fit_iterations: None,
347            single_fit_pixel: None,
348            single_fit_labels: None,
349            single_fit_anorm: None,
350            single_fit_background: None,
351            single_fit_baseline: None,
352            single_fit_baseline_e_ref_ev: None,
353            uncertainty_is_estimated: None,
354            lm_background_enabled: None,
355            kl_background_enabled: None,
356            baseline_enabled: None,
357            kl_c_ratio: None,
358            kl_enable_polish_override: None,
359            endf_cache: vec![],
360            provenance: vec![],
361        }
362    }
363}
364
365/// Borrowed references to raw data for embedded saves.
366///
367/// Avoids cloning multi-GB arrays into [`ProjectSnapshot`].
368/// Pass `None` for linked-mode saves.
369pub struct EmbeddedData<'a> {
370    pub sample: Option<&'a Array3<f64>>,
371    pub open_beam: Option<&'a Array3<f64>>,
372    pub spectrum: Option<&'a [f64]>,
373}
374
375/// Estimated compression ratio for gzip-4 on float64 neutron data.
376pub const EMBED_COMPRESSION_RATIO: f64 = 3.0;
377
378/// Estimate (uncompressed, compressed) byte sizes for embedding raw data.
379pub fn estimate_embedded_size(
380    sample: Option<&Array3<f64>>,
381    open_beam: Option<&Array3<f64>>,
382    spectrum: Option<&[f64]>,
383) -> (u64, u64) {
384    let mut raw: u64 = 0;
385    if let Some(s) = sample {
386        raw += (s.len() as u64) * 8;
387    }
388    if let Some(ob) = open_beam {
389        raw += (ob.len() as u64) * 8;
390    }
391    if let Some(sp) = spectrum {
392        raw += (sp.len() as u64) * 8;
393    }
394    let compressed = (raw as f64 / EMBED_COMPRESSION_RATIO) as u64;
395    (raw, compressed)
396}
397
398/// Write a project snapshot to an HDF5 file at `path` (linked mode).
399pub fn save_project(path: &Path, snap: &ProjectSnapshot) -> Result<(), IoError> {
400    save_project_with_data(path, snap, None)
401}
402
403/// Write a project snapshot with optional embedded raw data.
404///
405/// When `embedded` is `Some`, raw data arrays are written to `/data/embedded/`
406/// and the mode attribute is set to `"embedded"`. File paths in `/data/links/`
407/// are always written for provenance.
408pub fn save_project_with_data(
409    path: &Path,
410    snap: &ProjectSnapshot,
411    embedded: Option<&EmbeddedData<'_>>,
412) -> Result<(), IoError> {
413    let file = hdf5::File::create(path).map_err(|e| IoError::Hdf5Error(format!("create: {e}")))?;
414
415    write_meta(&file, snap)?;
416    write_config(&file, snap)?;
417    write_data_links(&file, snap, embedded)?;
418    write_intermediate(&file, snap)?;
419    write_results(&file, snap)?;
420    write_endf_cache(&file, snap)?;
421    write_provenance(&file, snap)?;
422
423    Ok(())
424}
425
426// ---------------------------------------------------------------------------
427// Internal helpers
428// ---------------------------------------------------------------------------
429
430fn hdf5_err(context: &str, e: impl std::fmt::Display) -> IoError {
431    IoError::Hdf5Error(format!("{context}: {e}"))
432}
433
434fn write_str_attr(loc: &hdf5::Group, name: &str, value: &str) -> Result<(), IoError> {
435    let val: VarLenUnicode = value.parse().map_err(|e| hdf5_err(name, e))?;
436    loc.new_attr::<VarLenUnicode>()
437        .shape(())
438        .create(name)
439        .and_then(|a| a.write_scalar(&val))
440        .map_err(|e| hdf5_err(name, e))
441}
442
443fn write_f64_attr(loc: &hdf5::Group, name: &str, value: f64) -> Result<(), IoError> {
444    loc.new_attr::<f64>()
445        .shape(())
446        .create(name)
447        .and_then(|a| a.write_scalar(&value))
448        .map_err(|e| hdf5_err(name, e))
449}
450
451fn write_u32_attr(loc: &hdf5::Group, name: &str, value: u32) -> Result<(), IoError> {
452    loc.new_attr::<u32>()
453        .shape(())
454        .create(name)
455        .and_then(|a| a.write_scalar(&value))
456        .map_err(|e| hdf5_err(name, e))
457}
458
459fn write_bool_attr(loc: &hdf5::Group, name: &str, value: bool) -> Result<(), IoError> {
460    let v: u8 = u8::from(value);
461    loc.new_attr::<u8>()
462        .shape(())
463        .create(name)
464        .and_then(|a| a.write_scalar(&v))
465        .map_err(|e| hdf5_err(name, e))
466}
467
468fn write_u64_attr(loc: &hdf5::Group, name: &str, value: u64) -> Result<(), IoError> {
469    loc.new_attr::<u64>()
470        .shape(())
471        .create(name)
472        .and_then(|a| a.write_scalar(&value))
473        .map_err(|e| hdf5_err(name, e))
474}
475
476/// Dataset-attribute variant of [`write_u32_attr`] (the group-typed
477/// helpers above predate any dataset carrying attributes).
478fn write_u32_attr_ds(ds: &hdf5::Dataset, name: &str, value: u32) -> Result<(), IoError> {
479    ds.new_attr::<u32>()
480        .shape(())
481        .create(name)
482        .and_then(|a| a.write_scalar(&value))
483        .map_err(|e| hdf5_err(name, e))
484}
485
486/// Dataset-attribute read: `Some(value)` if present and convertible,
487/// `None` otherwise (mirrors the `read_*_attr_opt` group helpers).
488fn read_u32_attr_ds_opt(ds: &hdf5::Dataset, name: &str) -> Option<u32> {
489    ds.attr(name).ok()?.read_scalar::<u32>().ok()
490}
491
492fn write_meta(file: &hdf5::File, snap: &ProjectSnapshot) -> Result<(), IoError> {
493    let g = file
494        .create_group("meta")
495        .map_err(|e| hdf5_err("create /meta", e))?;
496    write_str_attr(&g, "version", &snap.schema_version)?;
497    write_str_attr(&g, "created_utc", &snap.created_utc)?;
498    write_str_attr(&g, "software_version", &snap.software_version)?;
499    write_str_attr(&g, "fitting_type", &snap.fitting_type)?;
500    write_str_attr(&g, "data_type", &snap.data_type)?;
501    Ok(())
502}
503
504fn write_config(file: &hdf5::File, snap: &ProjectSnapshot) -> Result<(), IoError> {
505    let config = file
506        .create_group("config")
507        .map_err(|e| hdf5_err("create /config", e))?;
508
509    // Beamline
510    let bl = config
511        .create_group("beamline")
512        .map_err(|e| hdf5_err("create /config/beamline", e))?;
513    write_f64_attr(&bl, "flight_path_m", snap.flight_path_m)?;
514    write_f64_attr(&bl, "delay_us", snap.delay_us)?;
515    write_f64_attr(&bl, "proton_charge_sample", snap.proton_charge_sample)?;
516    write_f64_attr(&bl, "proton_charge_ob", snap.proton_charge_ob)?;
517
518    // Isotopes (parallel arrays as datasets)
519    let iso = config
520        .create_group("isotopes")
521        .map_err(|e| hdf5_err("create /config/isotopes", e))?;
522    let n = snap.isotope_z.len();
523    if n > 0 {
524        iso.new_dataset::<u32>()
525            .shape([n])
526            .create("z")
527            .and_then(|ds| ds.write_raw(&snap.isotope_z))
528            .map_err(|e| hdf5_err("/config/isotopes/z", e))?;
529
530        iso.new_dataset::<u32>()
531            .shape([n])
532            .create("a")
533            .and_then(|ds| ds.write_raw(&snap.isotope_a))
534            .map_err(|e| hdf5_err("/config/isotopes/a", e))?;
535
536        let symbols: Vec<VarLenUnicode> = snap
537            .isotope_symbol
538            .iter()
539            .map(|s| {
540                s.parse()
541                    .map_err(|e| hdf5_err("parse VarLenUnicode symbol", e))
542            })
543            .collect::<Result<Vec<_>, _>>()?;
544        iso.new_dataset::<VarLenUnicode>()
545            .shape([n])
546            .create("symbol")
547            .and_then(|ds| ds.write_raw(&symbols))
548            .map_err(|e| hdf5_err("/config/isotopes/symbol", e))?;
549
550        iso.new_dataset::<f64>()
551            .shape([n])
552            .create("density")
553            .and_then(|ds| ds.write_raw(&snap.isotope_density))
554            .map_err(|e| hdf5_err("/config/isotopes/density", e))?;
555
556        let enabled: Vec<u8> = snap.isotope_enabled.iter().map(|&b| u8::from(b)).collect();
557        iso.new_dataset::<u8>()
558            .shape([n])
559            .create("enabled")
560            .and_then(|ds| ds.write_raw(&enabled))
561            .map_err(|e| hdf5_err("/config/isotopes/enabled", e))?;
562    }
563
564    // Isotope groups (parallel arrays as datasets, with JSON for ragged members)
565    if !snap.isotope_group_z.is_empty() {
566        let ng = snap.isotope_group_z.len();
567        if snap.isotope_group_names.len() != ng
568            || snap.isotope_group_members_json.len() != ng
569            || snap.isotope_group_density.len() != ng
570            || snap.isotope_group_enabled.len() != ng
571        {
572            return Err(IoError::InvalidParameter(format!(
573                "isotope_group arrays have mismatched lengths: z={ng}, names={}, members={}, density={}, enabled={}",
574                snap.isotope_group_names.len(),
575                snap.isotope_group_members_json.len(),
576                snap.isotope_group_density.len(),
577                snap.isotope_group_enabled.len(),
578            )));
579        }
580
581        let ig = config
582            .create_group("isotope_groups")
583            .map_err(|e| hdf5_err("create /config/isotope_groups", e))?;
584
585        ig.new_dataset::<u32>()
586            .shape([ng])
587            .create("z")
588            .and_then(|ds| ds.write_raw(&snap.isotope_group_z))
589            .map_err(|e| hdf5_err("/config/isotope_groups/z", e))?;
590
591        let names: Vec<VarLenUnicode> = snap
592            .isotope_group_names
593            .iter()
594            .map(|s| {
595                s.parse()
596                    .map_err(|e| hdf5_err("parse VarLenUnicode group name", e))
597            })
598            .collect::<Result<Vec<_>, _>>()?;
599        ig.new_dataset::<VarLenUnicode>()
600            .shape([ng])
601            .create("names")
602            .and_then(|ds| ds.write_raw(&names))
603            .map_err(|e| hdf5_err("/config/isotope_groups/names", e))?;
604
605        let members_json: Vec<VarLenUnicode> = snap
606            .isotope_group_members_json
607            .iter()
608            .map(|s| {
609                s.parse()
610                    .map_err(|e| hdf5_err("parse VarLenUnicode group members_json", e))
611            })
612            .collect::<Result<Vec<_>, _>>()?;
613        ig.new_dataset::<VarLenUnicode>()
614            .shape([ng])
615            .create("members_json")
616            .and_then(|ds| ds.write_raw(&members_json))
617            .map_err(|e| hdf5_err("/config/isotope_groups/members_json", e))?;
618
619        ig.new_dataset::<f64>()
620            .shape([ng])
621            .create("density")
622            .and_then(|ds| ds.write_raw(&snap.isotope_group_density))
623            .map_err(|e| hdf5_err("/config/isotope_groups/density", e))?;
624
625        let g_enabled: Vec<u8> = snap
626            .isotope_group_enabled
627            .iter()
628            .map(|&b| u8::from(b))
629            .collect();
630        ig.new_dataset::<u8>()
631            .shape([ng])
632            .create("enabled")
633            .and_then(|ds| ds.write_raw(&g_enabled))
634            .map_err(|e| hdf5_err("/config/isotope_groups/enabled", e))?;
635    }
636
637    // Workflow mode + event params
638    write_str_attr(&config, "input_mode", &snap.input_mode)?;
639    write_str_attr(&config, "analysis_mode", &snap.analysis_mode)?;
640    if let Some(factor) = snap.spatial_binning_factor {
641        write_u32_attr(&config, "spatial_binning_factor", factor as u32)?;
642    }
643
644    if snap.event_n_bins > 0 {
645        let ep = config
646            .create_group("event_params")
647            .map_err(|e| hdf5_err("create /config/event_params", e))?;
648        write_u32_attr(&ep, "n_bins", snap.event_n_bins)?;
649        write_f64_attr(&ep, "tof_min_us", snap.event_tof_min_us)?;
650        write_f64_attr(&ep, "tof_max_us", snap.event_tof_max_us)?;
651        write_u32_attr(&ep, "height", snap.event_height)?;
652        write_u32_attr(&ep, "width", snap.event_width)?;
653    }
654
655    // Solver
656    let solver = config
657        .create_group("solver")
658        .map_err(|e| hdf5_err("create /config/solver", e))?;
659    write_str_attr(&solver, "method", &snap.solver_method)?;
660    write_u32_attr(&solver, "max_iter", snap.max_iter)?;
661    write_f64_attr(&solver, "temperature_k", snap.temperature_k)?;
662    write_bool_attr(&solver, "fit_temperature", snap.fit_temperature)?;
663    if let Some(fes) = snap.fit_energy_scale {
664        write_bool_attr(&solver, "fit_energy_scale", fes)?;
665    }
666    if let Some((fr_min, fr_max)) = snap.fit_energy_range {
667        // SAMMY EMIN/EMAX-equivalent fit-energy-range bounds (#514).
668        // Stored as two scalar attributes so HDF5 introspection tools
669        // can read the values directly without parsing a packed tuple.
670        write_f64_attr(&solver, "fit_energy_range_min_ev", fr_min)?;
671        write_f64_attr(&solver, "fit_energy_range_max_ev", fr_max)?;
672    }
673    if let Some(ue) = snap.uncertainty_is_estimated {
674        write_bool_attr(&solver, "uncertainty_is_estimated", ue)?;
675    }
676    if let Some(lm_bg) = snap.lm_background_enabled {
677        write_bool_attr(&solver, "lm_background_enabled", lm_bg)?;
678    }
679    if let Some(bl) = snap.baseline_enabled {
680        write_bool_attr(&solver, "baseline_enabled", bl)?;
681    }
682    if let Some(kl_bg) = snap.kl_background_enabled {
683        write_bool_attr(&solver, "kl_background_enabled", kl_bg)?;
684    }
685    if let Some(c) = snap.kl_c_ratio {
686        write_f64_attr(&solver, "kl_c_ratio", c)?;
687    }
688    // Tri-state polish override is encoded as a string attribute so the
689    // distinction between "auto" (None) and "Some(false)" is preserved.
690    if let Some(polish) = snap.kl_enable_polish_override {
691        let s = match polish {
692            None => "auto",
693            Some(true) => "on",
694            Some(false) => "off",
695        };
696        write_str_attr(&solver, "kl_polish_override", s)?;
697    }
698
699    // Resolution
700    let res = config
701        .create_group("resolution")
702        .map_err(|e| hdf5_err("create /config/resolution", e))?;
703    write_bool_attr(&res, "enabled", snap.resolution_enabled)?;
704    write_str_attr(&res, "kind", &snap.resolution_kind)?;
705    if let Some(dt) = snap.delta_t_us {
706        write_f64_attr(&res, "delta_t_us", dt)?;
707    }
708    if let Some(dl) = snap.delta_l_m {
709        write_f64_attr(&res, "delta_l_m", dl)?;
710    }
711    if let Some(ref tp) = snap.tabulated_path {
712        write_str_attr(&res, "tabulated_path", tp)?;
713    }
714
715    // ROIs
716    if !snap.rois.is_empty() {
717        let n_rois = snap.rois.len();
718        let flat: Vec<u64> = snap.rois.iter().flat_map(|r| r.iter().copied()).collect();
719        config
720            .new_dataset::<u64>()
721            .shape([n_rois, 4])
722            .create("rois")
723            .and_then(|ds| ds.write_raw(&flat))
724            .map_err(|e| hdf5_err("/config/rois", e))?;
725    }
726
727    // ENDF library
728    write_str_attr(&config, "endf_library", &snap.endf_library)?;
729
730    Ok(())
731}
732
733fn write_data_links(
734    file: &hdf5::File,
735    snap: &ProjectSnapshot,
736    embedded: Option<&EmbeddedData<'_>>,
737) -> Result<(), IoError> {
738    let data = file
739        .create_group("data")
740        .map_err(|e| hdf5_err("create /data", e))?;
741
742    let mode = if embedded.is_some() {
743        "embedded"
744    } else {
745        &snap.data_mode
746    };
747    write_str_attr(&data, "mode", mode)?;
748    write_str_attr(&data, "spectrum_unit", &snap.spectrum_unit)?;
749    write_str_attr(&data, "spectrum_kind", &snap.spectrum_kind)?;
750    write_u32_attr(&data, "rebin_factor", snap.rebin_factor)?;
751    write_bool_attr(&data, "rebin_applied", snap.rebin_applied)?;
752
753    // Always write links for provenance (original file paths)
754    let links = data
755        .create_group("links")
756        .map_err(|e| hdf5_err("create /data/links", e))?;
757    if let Some(ref p) = snap.sample_path {
758        write_str_attr(&links, "sample_path", p)?;
759    }
760    if let Some(ref p) = snap.open_beam_path {
761        write_str_attr(&links, "open_beam_path", p)?;
762    }
763    if let Some(ref p) = snap.spectrum_path {
764        write_str_attr(&links, "spectrum_path", p)?;
765    }
766    if let Some(ref p) = snap.hdf5_path {
767        write_str_attr(&links, "hdf5_path", p)?;
768    }
769    if let Some(ref p) = snap.hdf5_ob_path {
770        write_str_attr(&links, "hdf5_ob_path", p)?;
771    }
772
773    // Write embedded data if present
774    if let Some(emb) = embedded {
775        write_embedded_data(&data, emb)?;
776    }
777
778    Ok(())
779}
780
781fn write_embedded_data(data_group: &hdf5::Group, emb: &EmbeddedData<'_>) -> Result<(), IoError> {
782    let embedded = data_group
783        .create_group("embedded")
784        .map_err(|e| hdf5_err("create /data/embedded", e))?;
785
786    if let Some(sample) = emb.sample {
787        write_chunked_3d(&embedded, "sample", sample, "/data/embedded")?;
788    }
789
790    if let Some(ob) = emb.open_beam {
791        write_chunked_3d(&embedded, "open_beam", ob, "/data/embedded")?;
792    }
793
794    if let Some(spectrum) = emb.spectrum {
795        embedded
796            .new_dataset::<f64>()
797            .shape([spectrum.len()])
798            .deflate(4)
799            .create("spectrum")
800            .and_then(|ds| ds.write_raw(spectrum))
801            .map_err(|e| hdf5_err("/data/embedded/spectrum", e))?;
802    }
803
804    Ok(())
805}
806
807/// Write a 3D f64 array as a chunked, gzip-compressed dataset.
808///
809/// Uses `as_standard_layout()` to get a contiguous view without allocating
810/// when the array is already in standard (row-major) layout. Only copies
811/// if the array has non-standard strides.
812///
813/// Zero-dimension arrays are silently skipped (nothing to write).
814fn write_chunked_3d(
815    group: &hdf5::Group,
816    name: &str,
817    arr: &Array3<f64>,
818    path_prefix: &str,
819) -> Result<(), IoError> {
820    let shape = [arr.shape()[0], arr.shape()[1], arr.shape()[2]];
821    if shape.contains(&0) {
822        return Ok(());
823    }
824    let contiguous = arr.as_standard_layout();
825    let slice = contiguous.as_slice().ok_or_else(|| {
826        hdf5_err(
827            &format!("{path_prefix}/{name}"),
828            "array is not contiguous after as_standard_layout",
829        )
830    })?;
831    group
832        .new_dataset::<f64>()
833        .shape(shape)
834        .chunk(chunk_shape_3d(shape))
835        .deflate(4)
836        .create(name)
837        .and_then(|ds| ds.write_raw(slice))
838        .map_err(|e| hdf5_err(&format!("{path_prefix}/{name}"), e))?;
839    Ok(())
840}
841
842fn write_intermediate(file: &hdf5::File, snap: &ProjectSnapshot) -> Result<(), IoError> {
843    let inter = file
844        .create_group("intermediate")
845        .map_err(|e| hdf5_err("create /intermediate", e))?;
846
847    if let Some(ref norm) = snap.normalized {
848        write_chunked_3d(&inter, "normalized", norm, "/intermediate")?;
849    }
850
851    // D-1: Save per-bin transmission uncertainty alongside normalized data.
852    if let Some(ref unc) = snap.normalized_uncertainty {
853        write_chunked_3d(&inter, "normalized_uncertainty", unc, "/intermediate")?;
854    }
855
856    if let Some(ref energies) = snap.energies {
857        inter
858            .new_dataset::<f64>()
859            .shape([energies.len()])
860            .create("energies")
861            .and_then(|ds| ds.write_raw(energies))
862            .map_err(|e| hdf5_err("/intermediate/energies", e))?;
863    }
864
865    // D-20: Persist dead-pixel mask as u8 (0 = live, 1 = dead).
866    if let Some(ref dp) = snap.dead_pixels {
867        let shape = [dp.shape()[0], dp.shape()[1]];
868        if !shape.contains(&0) {
869            let data: Vec<u8> = dp.iter().map(|&b| u8::from(b)).collect();
870            inter
871                .new_dataset::<u8>()
872                .shape(shape)
873                .create("dead_pixels")
874                .and_then(|ds| ds.write_raw(&data))
875                .map_err(|e| hdf5_err("/intermediate/dead_pixels", e))?;
876        }
877    }
878
879    // #646 R4 P1-1: Persist the DETECTED mask component as its own
880    // session-scoped, versioned dataset (u8, 0 = live, 1 = excluded) —
881    // see the field rustdoc on `ProjectSnapshot::detected_dead_pixels`.
882    if let Some(ref det) = snap.detected_dead_pixels {
883        let shape = [det.shape()[0], det.shape()[1]];
884        if !shape.contains(&0) {
885            let data: Vec<u8> = det.iter().map(|&b| u8::from(b)).collect();
886            let ds = inter
887                .new_dataset::<u8>()
888                .shape(shape)
889                .create("detected_dead_pixels")
890                .map_err(|e| hdf5_err("/intermediate/detected_dead_pixels", e))?;
891            ds.write_raw(&data)
892                .map_err(|e| hdf5_err("/intermediate/detected_dead_pixels", e))?;
893            write_u32_attr_ds(&ds, "format_version", DETECTED_DEAD_PIXELS_FORMAT_VERSION)?;
894        }
895    }
896
897    Ok(())
898}
899
900fn write_results(file: &hdf5::File, snap: &ProjectSnapshot) -> Result<(), IoError> {
901    let results = file
902        .create_group("results")
903        .map_err(|e| hdf5_err("create /results", e))?;
904
905    if let Some(ref maps) = snap.density_maps {
906        let density = results
907            .create_group("density")
908            .map_err(|e| hdf5_err("create /results/density", e))?;
909        let labels = snap.result_isotope_labels.as_deref().unwrap_or_default();
910        for (i, map) in maps.iter().enumerate() {
911            let name = labels
912                .get(i)
913                .map_or_else(|| format!("isotope_{i}"), |s| s.clone());
914            let shape = [map.shape()[0], map.shape()[1]];
915            let data: Vec<f64> = map.iter().copied().collect();
916            density
917                .new_dataset::<f64>()
918                .shape(shape)
919                .chunk(shape)
920                .deflate(4)
921                .create(name.as_str())
922                .and_then(|ds| ds.write_raw(&data))
923                .map_err(|e| hdf5_err(&format!("/results/density/{name}"), e))?;
924        }
925    }
926
927    if let Some(ref maps) = snap.uncertainty_maps {
928        let unc = results
929            .create_group("uncertainty")
930            .map_err(|e| hdf5_err("create /results/uncertainty", e))?;
931        let labels = snap.result_isotope_labels.as_deref().unwrap_or_default();
932        for (i, map) in maps.iter().enumerate() {
933            let name = labels
934                .get(i)
935                .map_or_else(|| format!("isotope_{i}"), |s| s.clone());
936            let shape = [map.shape()[0], map.shape()[1]];
937            let data: Vec<f64> = map.iter().copied().collect();
938            unc.new_dataset::<f64>()
939                .shape(shape)
940                .chunk(shape)
941                .deflate(4)
942                .create(name.as_str())
943                .and_then(|ds| ds.write_raw(&data))
944                .map_err(|e| hdf5_err(&format!("/results/uncertainty/{name}"), e))?;
945        }
946    }
947
948    if let Some(ref chi2) = snap.chi_squared_map {
949        let shape = [chi2.shape()[0], chi2.shape()[1]];
950        let data: Vec<f64> = chi2.iter().copied().collect();
951        results
952            .new_dataset::<f64>()
953            .shape(shape)
954            .chunk(shape)
955            .deflate(4)
956            .create("chi_squared")
957            .and_then(|ds| ds.write_raw(&data))
958            .map_err(|e| hdf5_err("/results/chi_squared", e))?;
959    }
960
961    if let Some(ref conv) = snap.converged_map {
962        let shape = [conv.shape()[0], conv.shape()[1]];
963        let data: Vec<u8> = conv.iter().map(|&b| u8::from(b)).collect();
964        results
965            .new_dataset::<u8>()
966            .shape(shape)
967            .chunk(shape)
968            .deflate(4)
969            .create("converged")
970            .and_then(|ds| ds.write_raw(&data))
971            .map_err(|e| hdf5_err("/results/converged", e))?;
972    }
973
974    if let Some(ref t_map) = snap.temperature_map {
975        let shape = [t_map.shape()[0], t_map.shape()[1]];
976        let data: Vec<f64> = t_map.iter().copied().collect();
977        results
978            .new_dataset::<f64>()
979            .shape(shape)
980            .chunk(shape)
981            .deflate(4)
982            .create("temperature")
983            .and_then(|ds| ds.write_raw(&data))
984            .map_err(|e| hdf5_err("/results/temperature", e))?;
985    }
986
987    if let Some(ref tu_map) = snap.temperature_uncertainty_map {
988        let shape = [tu_map.shape()[0], tu_map.shape()[1]];
989        let data: Vec<f64> = tu_map.iter().copied().collect();
990        results
991            .new_dataset::<f64>()
992            .shape(shape)
993            .chunk(shape)
994            .deflate(4)
995            .create("temperature_uncertainty")
996            .and_then(|ds| ds.write_raw(&data))
997            .map_err(|e| hdf5_err("/results/temperature_uncertainty", e))?;
998    }
999
1000    // Save anorm and background maps from spatial fitting.
1001    if let Some(ref a_map) = snap.anorm_map {
1002        let shape = [a_map.shape()[0], a_map.shape()[1]];
1003        let data: Vec<f64> = a_map.iter().copied().collect();
1004        results
1005            .new_dataset::<f64>()
1006            .shape(shape)
1007            .chunk(shape)
1008            .deflate(4)
1009            .create("anorm")
1010            .and_then(|ds| ds.write_raw(&data))
1011            .map_err(|e| hdf5_err("/results/anorm", e))?;
1012    }
1013
1014    if let Some(ref bg_maps) = snap.background_maps {
1015        let bg_grp = results
1016            .create_group("background")
1017            .map_err(|e| hdf5_err("create /results/background", e))?;
1018        for (i, &label) in ["back_a", "back_b", "back_c"].iter().enumerate() {
1019            let m = &bg_maps[i];
1020            let shape = [m.shape()[0], m.shape()[1]];
1021            let data: Vec<f64> = m.iter().copied().collect();
1022            bg_grp
1023                .new_dataset::<f64>()
1024                .shape(shape)
1025                .chunk(shape)
1026                .deflate(4)
1027                .create(label)
1028                .and_then(|ds| ds.write_raw(&data))
1029                .map_err(|e| hdf5_err(&format!("/results/background/{label}"), e))?;
1030        }
1031    }
1032
1033    // Issue #635: multiplicative-baseline outputs.  The overlay rebuilds
1034    // B(E) from these on project load — dropping them would silently render
1035    // a different scientific model than the one that was fitted.
1036    if let Some(bl) = snap.baseline_global {
1037        results
1038            .new_dataset::<f64>()
1039            .shape([3])
1040            .create("baseline_global")
1041            .and_then(|ds| ds.write_raw(&bl))
1042            .map_err(|e| hdf5_err("/results/baseline_global", e))?;
1043    }
1044    if let Some(e_ref) = snap.baseline_e_ref_ev {
1045        write_f64_attr(&results, "baseline_e_ref_ev", e_ref)?;
1046    }
1047    if let Some(ref bl_maps) = snap.baseline_maps {
1048        let bl_grp = results
1049            .create_group("baseline")
1050            .map_err(|e| hdf5_err("create /results/baseline", e))?;
1051        for (i, &label) in ["b0", "b1", "b2"].iter().enumerate() {
1052            let m = &bl_maps[i];
1053            let shape = [m.shape()[0], m.shape()[1]];
1054            let data: Vec<f64> = m.iter().copied().collect();
1055            bl_grp
1056                .new_dataset::<f64>()
1057                .shape(shape)
1058                .chunk(shape)
1059                .deflate(4)
1060                .create(label)
1061                .and_then(|ds| ds.write_raw(&data))
1062                .map_err(|e| hdf5_err(&format!("/results/baseline/{label}"), e))?;
1063        }
1064    }
1065
1066    if let Some(nc) = snap.n_converged {
1067        write_u64_attr(&results, "n_converged", nc as u64)?;
1068    }
1069    if let Some(nt) = snap.n_total {
1070        write_u64_attr(&results, "n_total", nt as u64)?;
1071    }
1072    if let Some(nf) = snap.n_failed {
1073        write_u64_attr(&results, "n_failed", nf as u64)?;
1074    }
1075
1076    if let Some(ref labels) = snap.result_isotope_labels
1077        && !labels.is_empty()
1078    {
1079        let vlu: Vec<VarLenUnicode> = labels
1080            .iter()
1081            .map(|s| {
1082                s.parse()
1083                    .map_err(|e| hdf5_err("parse VarLenUnicode label", e))
1084            })
1085            .collect::<Result<Vec<_>, _>>()?;
1086        results
1087            .new_dataset::<VarLenUnicode>()
1088            .shape([labels.len()])
1089            .create("result_isotopes")
1090            .and_then(|ds| ds.write_raw(&vlu))
1091            .map_err(|e| hdf5_err("/results/result_isotopes", e))?;
1092    }
1093
1094    // Single-pixel fit results (optional)
1095    if let Some(ref densities) = snap.single_fit_densities {
1096        let sf = results
1097            .create_group("single_fit")
1098            .map_err(|e| hdf5_err("create /results/single_fit", e))?;
1099        sf.new_dataset::<f64>()
1100            .shape([densities.len()])
1101            .create("densities")
1102            .and_then(|ds| ds.write_raw(densities))
1103            .map_err(|e| hdf5_err("/results/single_fit/densities", e))?;
1104        if let Some(ref unc) = snap.single_fit_uncertainties {
1105            sf.new_dataset::<f64>()
1106                .shape([unc.len()])
1107                .create("uncertainties")
1108                .and_then(|ds| ds.write_raw(unc))
1109                .map_err(|e| hdf5_err("/results/single_fit/uncertainties", e))?;
1110        }
1111        if let Some(chi2) = snap.single_fit_chi_squared {
1112            write_f64_attr(&sf, "chi_squared", chi2)?;
1113        }
1114        if let Some(temp) = snap.single_fit_temperature {
1115            write_f64_attr(&sf, "temperature_k", temp)?;
1116        }
1117        if let Some(temp_unc) = snap.single_fit_temperature_unc {
1118            write_f64_attr(&sf, "temperature_k_unc", temp_unc)?;
1119        }
1120        if let Some(iterations) = snap.single_fit_iterations {
1121            write_u32_attr(&sf, "iterations", iterations as u32)?;
1122        }
1123        if let Some(conv) = snap.single_fit_converged {
1124            write_bool_attr(&sf, "converged", conv)?;
1125        }
1126        if let Some((py, px)) = snap.single_fit_pixel {
1127            write_u32_attr(&sf, "pixel_y", py as u32)?;
1128            write_u32_attr(&sf, "pixel_x", px as u32)?;
1129        }
1130        if let Some(ref labels) = snap.single_fit_labels {
1131            let vlu: Vec<VarLenUnicode> = labels
1132                .iter()
1133                .map(|s| s.parse().map_err(|e| hdf5_err("parse single_fit label", e)))
1134                .collect::<Result<Vec<_>, _>>()?;
1135            sf.new_dataset::<VarLenUnicode>()
1136                .shape([labels.len()])
1137                .create("isotope_labels")
1138                .and_then(|ds| ds.write_raw(&vlu))
1139                .map_err(|e| hdf5_err("/results/single_fit/isotope_labels", e))?;
1140        }
1141        if let Some(anorm) = snap.single_fit_anorm {
1142            write_f64_attr(&sf, "anorm", anorm)?;
1143        }
1144        if let Some(bg) = snap.single_fit_background {
1145            sf.new_dataset::<f64>()
1146                .shape([3])
1147                .create("background")
1148                .and_then(|ds| ds.write_raw(&bg))
1149                .map_err(|e| hdf5_err("/results/single_fit/background", e))?;
1150        }
1151        if let Some(bl) = snap.single_fit_baseline {
1152            sf.new_dataset::<f64>()
1153                .shape([3])
1154                .create("baseline")
1155                .and_then(|ds| ds.write_raw(&bl))
1156                .map_err(|e| hdf5_err("/results/single_fit/baseline", e))?;
1157        }
1158        if let Some(e_ref) = snap.single_fit_baseline_e_ref_ev {
1159            write_f64_attr(&sf, "baseline_e_ref_ev", e_ref)?;
1160        }
1161    }
1162
1163    Ok(())
1164}
1165
1166fn write_endf_cache(file: &hdf5::File, snap: &ProjectSnapshot) -> Result<(), IoError> {
1167    let cache = file
1168        .create_group("endf_cache")
1169        .map_err(|e| hdf5_err("create /endf_cache", e))?;
1170
1171    let mut written = std::collections::HashSet::new();
1172    for (symbol, rd) in &snap.endf_cache {
1173        if !written.insert(symbol.clone()) {
1174            continue; // skip duplicate symbol — first entry wins
1175        }
1176        let iso_group = cache
1177            .create_group(symbol)
1178            .map_err(|e| hdf5_err(&format!("create /endf_cache/{symbol}"), e))?;
1179
1180        let json = serde_json::to_string(rd)
1181            .map_err(|e| hdf5_err(&format!("serialize /endf_cache/{symbol}"), e))?;
1182        let vlu: VarLenUnicode = json.parse().map_err(|e| hdf5_err(symbol, e))?;
1183        iso_group
1184            .new_dataset::<VarLenUnicode>()
1185            .shape(())
1186            .create("resonance_data")
1187            .and_then(|ds| ds.write_scalar(&vlu))
1188            .map_err(|e| hdf5_err(&format!("/endf_cache/{symbol}/resonance_data"), e))?;
1189    }
1190
1191    Ok(())
1192}
1193
1194fn write_provenance(file: &hdf5::File, snap: &ProjectSnapshot) -> Result<(), IoError> {
1195    let prov = file
1196        .create_group("provenance")
1197        .map_err(|e| hdf5_err("create /provenance", e))?;
1198
1199    if snap.provenance.is_empty() {
1200        return Ok(());
1201    }
1202
1203    let n = snap.provenance.len();
1204    let timestamps: Vec<VarLenUnicode> = snap
1205        .provenance
1206        .iter()
1207        .map(|(ts, _, _)| {
1208            ts.parse()
1209                .map_err(|e| hdf5_err("parse VarLenUnicode timestamp", e))
1210        })
1211        .collect::<Result<Vec<_>, _>>()?;
1212    let kinds: Vec<VarLenUnicode> = snap
1213        .provenance
1214        .iter()
1215        .map(|(_, k, _)| {
1216            k.parse()
1217                .map_err(|e| hdf5_err("parse VarLenUnicode kind", e))
1218        })
1219        .collect::<Result<Vec<_>, _>>()?;
1220    let messages: Vec<VarLenUnicode> = snap
1221        .provenance
1222        .iter()
1223        .map(|(_, _, m)| {
1224            m.parse()
1225                .map_err(|e| hdf5_err("parse VarLenUnicode message", e))
1226        })
1227        .collect::<Result<Vec<_>, _>>()?;
1228
1229    prov.new_dataset::<VarLenUnicode>()
1230        .shape([n])
1231        .create("timestamps")
1232        .and_then(|ds| ds.write_raw(&timestamps))
1233        .map_err(|e| hdf5_err("/provenance/timestamps", e))?;
1234
1235    prov.new_dataset::<VarLenUnicode>()
1236        .shape([n])
1237        .create("kinds")
1238        .and_then(|ds| ds.write_raw(&kinds))
1239        .map_err(|e| hdf5_err("/provenance/kinds", e))?;
1240
1241    prov.new_dataset::<VarLenUnicode>()
1242        .shape([n])
1243        .create("messages")
1244        .and_then(|ds| ds.write_raw(&messages))
1245        .map_err(|e| hdf5_err("/provenance/messages", e))?;
1246
1247    Ok(())
1248}
1249
1250/// Pick a reasonable chunk shape for a 3D dataset.
1251fn chunk_shape_3d(shape: [usize; 3]) -> [usize; 3] {
1252    // One full frame per chunk, capped at 256 frames.
1253    // Guard zero dimensions — HDF5 rejects zero-sized chunks.
1254    let frames = shape[0].clamp(1, 256);
1255    [frames, shape[1].max(1), shape[2].max(1)]
1256}
1257
1258// ---------------------------------------------------------------------------
1259// Read helpers
1260// ---------------------------------------------------------------------------
1261
1262fn read_str_attr(loc: &hdf5::Group, name: &str) -> Result<String, IoError> {
1263    let val: VarLenUnicode = loc
1264        .attr(name)
1265        .and_then(|a| a.read_scalar())
1266        .map_err(|e| hdf5_err(name, e))?;
1267    Ok(val.as_str().to_string())
1268}
1269
1270fn read_f64_attr(loc: &hdf5::Group, name: &str) -> Result<f64, IoError> {
1271    loc.attr(name)
1272        .and_then(|a| a.read_scalar())
1273        .map_err(|e| hdf5_err(name, e))
1274}
1275
1276fn read_u32_attr(loc: &hdf5::Group, name: &str) -> Result<u32, IoError> {
1277    loc.attr(name)
1278        .and_then(|a| a.read_scalar())
1279        .map_err(|e| hdf5_err(name, e))
1280}
1281
1282fn read_bool_attr(loc: &hdf5::Group, name: &str) -> Result<bool, IoError> {
1283    let v: u8 = loc
1284        .attr(name)
1285        .and_then(|a| a.read_scalar())
1286        .map_err(|e| hdf5_err(name, e))?;
1287    Ok(v != 0)
1288}
1289
1290fn read_u64_attr(loc: &hdf5::Group, name: &str) -> Result<u64, IoError> {
1291    loc.attr(name)
1292        .and_then(|a| a.read_scalar())
1293        .map_err(|e| hdf5_err(name, e))
1294}
1295
1296/// Return `None` if the attribute does not exist.
1297fn read_str_attr_opt(loc: &hdf5::Group, name: &str) -> Option<String> {
1298    loc.attr(name)
1299        .and_then(|a| a.read_scalar::<VarLenUnicode>())
1300        .ok()
1301        .map(|v| v.as_str().to_string())
1302}
1303
1304/// Return `None` if the attribute does not exist.
1305fn read_f64_attr_opt(loc: &hdf5::Group, name: &str) -> Option<f64> {
1306    loc.attr(name).and_then(|a| a.read_scalar::<f64>()).ok()
1307}
1308
1309// ---------------------------------------------------------------------------
1310// Load
1311// ---------------------------------------------------------------------------
1312
1313/// Load a project snapshot from an HDF5 file at `path`.
1314pub fn load_project(path: &Path) -> Result<ProjectSnapshot, IoError> {
1315    let file = hdf5::File::open(path).map_err(|e| IoError::Hdf5Error(format!("open: {e}")))?;
1316
1317    let mut snap = read_meta(&file)?;
1318    read_config(&file, &mut snap)?;
1319    read_data_links(&file, &mut snap)?;
1320    read_intermediate(&file, &mut snap)?;
1321    read_results(&file, &mut snap)?;
1322    read_endf_cache_into(&file, &mut snap)?;
1323    read_provenance_into(&file, &mut snap)?;
1324
1325    Ok(snap)
1326}
1327
1328fn read_meta(file: &hdf5::File) -> Result<ProjectSnapshot, IoError> {
1329    let g = file.group("meta").map_err(|_| {
1330        IoError::Hdf5Error("Not a valid NEREIDS project file: missing schema version".to_string())
1331    })?;
1332
1333    let schema_version = read_str_attr(&g, "version").map_err(|_| {
1334        IoError::Hdf5Error("Not a valid NEREIDS project file: missing schema version".to_string())
1335    })?;
1336    let created_utc = read_str_attr(&g, "created_utc")?;
1337    let software_version = read_str_attr(&g, "software_version")?;
1338    let fitting_type = read_str_attr(&g, "fitting_type")?;
1339    let data_type = read_str_attr(&g, "data_type")?;
1340
1341    Ok(ProjectSnapshot {
1342        schema_version,
1343        created_utc,
1344        software_version,
1345        fitting_type,
1346        data_type,
1347        ..Default::default()
1348    })
1349}
1350
1351fn read_config(file: &hdf5::File, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
1352    let config = file
1353        .group("config")
1354        .map_err(|e| hdf5_err("open /config", e))?;
1355
1356    // Beamline
1357    let bl = config
1358        .group("beamline")
1359        .map_err(|e| hdf5_err("open /config/beamline", e))?;
1360    snap.flight_path_m = read_f64_attr(&bl, "flight_path_m")?;
1361    snap.delay_us = read_f64_attr(&bl, "delay_us")?;
1362    snap.proton_charge_sample = read_f64_attr(&bl, "proton_charge_sample")?;
1363    snap.proton_charge_ob = read_f64_attr(&bl, "proton_charge_ob")?;
1364
1365    // Isotopes
1366    let iso = config
1367        .group("isotopes")
1368        .map_err(|e| hdf5_err("open /config/isotopes", e))?;
1369    if iso.dataset("z").is_ok() {
1370        let z_ds = iso
1371            .dataset("z")
1372            .map_err(|e| hdf5_err("/config/isotopes/z", e))?;
1373        snap.isotope_z = z_ds
1374            .read_raw()
1375            .map_err(|e| hdf5_err("/config/isotopes/z", e))?;
1376
1377        let a_ds = iso
1378            .dataset("a")
1379            .map_err(|e| hdf5_err("/config/isotopes/a", e))?;
1380        snap.isotope_a = a_ds
1381            .read_raw()
1382            .map_err(|e| hdf5_err("/config/isotopes/a", e))?;
1383
1384        let sym_ds = iso
1385            .dataset("symbol")
1386            .map_err(|e| hdf5_err("/config/isotopes/symbol", e))?;
1387        let symbols: Vec<VarLenUnicode> = sym_ds
1388            .read_raw()
1389            .map_err(|e| hdf5_err("/config/isotopes/symbol", e))?;
1390        snap.isotope_symbol = symbols.iter().map(|v| v.as_str().to_string()).collect();
1391
1392        let d_ds = iso
1393            .dataset("density")
1394            .map_err(|e| hdf5_err("/config/isotopes/density", e))?;
1395        snap.isotope_density = d_ds
1396            .read_raw()
1397            .map_err(|e| hdf5_err("/config/isotopes/density", e))?;
1398
1399        let en_ds = iso
1400            .dataset("enabled")
1401            .map_err(|e| hdf5_err("/config/isotopes/enabled", e))?;
1402        let en_raw: Vec<u8> = en_ds
1403            .read_raw()
1404            .map_err(|e| hdf5_err("/config/isotopes/enabled", e))?;
1405        snap.isotope_enabled = en_raw.iter().map(|&v| v != 0).collect();
1406    }
1407
1408    // Solver
1409    let solver = config
1410        .group("solver")
1411        .map_err(|e| hdf5_err("open /config/solver", e))?;
1412    snap.solver_method = read_str_attr(&solver, "method")?;
1413    snap.max_iter = read_u32_attr(&solver, "max_iter")?;
1414    snap.temperature_k = read_f64_attr(&solver, "temperature_k")?;
1415    snap.fit_temperature = read_bool_attr(&solver, "fit_temperature")?;
1416    snap.fit_energy_scale = read_bool_attr(&solver, "fit_energy_scale").ok();
1417    // SAMMY EMIN/EMAX-equivalent fit-energy-range bounds (#514).  Both
1418    // attributes must be present to populate the field; older project
1419    // files load as `None` (= full-grid fit, the prior default).
1420    snap.fit_energy_range = match (
1421        read_f64_attr(&solver, "fit_energy_range_min_ev").ok(),
1422        read_f64_attr(&solver, "fit_energy_range_max_ev").ok(),
1423    ) {
1424        (Some(min), Some(max)) => Some((min, max)),
1425        _ => None,
1426    };
1427    snap.uncertainty_is_estimated = read_bool_attr(&solver, "uncertainty_is_estimated").ok();
1428    snap.lm_background_enabled = read_bool_attr(&solver, "lm_background_enabled").ok();
1429    snap.baseline_enabled = read_bool_attr(&solver, "baseline_enabled").ok();
1430    snap.kl_background_enabled = read_bool_attr(&solver, "kl_background_enabled").ok();
1431    snap.kl_c_ratio = read_f64_attr(&solver, "kl_c_ratio").ok();
1432    snap.kl_enable_polish_override =
1433        read_str_attr(&solver, "kl_polish_override")
1434            .ok()
1435            .map(|s| match s.as_str() {
1436                "on" => Some(true),
1437                "off" => Some(false),
1438                _ => None,
1439            });
1440
1441    // Resolution
1442    let res = config
1443        .group("resolution")
1444        .map_err(|e| hdf5_err("open /config/resolution", e))?;
1445    snap.resolution_enabled = read_bool_attr(&res, "enabled")?;
1446    snap.resolution_kind = read_str_attr(&res, "kind")?;
1447    snap.delta_t_us = read_f64_attr_opt(&res, "delta_t_us");
1448    snap.delta_l_m = read_f64_attr_opt(&res, "delta_l_m");
1449    snap.tabulated_path = read_str_attr_opt(&res, "tabulated_path");
1450
1451    // ROIs
1452    if let Ok(roi_ds) = config.dataset("rois") {
1453        let shape = roi_ds.shape();
1454        let flat: Vec<u64> = roi_ds.read_raw().map_err(|e| hdf5_err("/config/rois", e))?;
1455        if shape.len() == 2 && shape[1] == 4 {
1456            snap.rois = flat.chunks(4).map(|c| [c[0], c[1], c[2], c[3]]).collect();
1457        }
1458    }
1459
1460    // Isotope groups (backward-compatible: old files may lack this group)
1461    if let Ok(ig) = config.group("isotope_groups") {
1462        if let Ok(z_ds) = ig.dataset("z") {
1463            snap.isotope_group_z = z_ds
1464                .read_raw()
1465                .map_err(|e| hdf5_err("/config/isotope_groups/z", e))?;
1466        }
1467        if let Ok(names_ds) = ig.dataset("names") {
1468            let names_vlu: Vec<VarLenUnicode> = names_ds
1469                .read_raw()
1470                .map_err(|e| hdf5_err("/config/isotope_groups/names", e))?;
1471            snap.isotope_group_names = names_vlu.iter().map(|v| v.as_str().to_string()).collect();
1472        }
1473        if let Ok(mj_ds) = ig.dataset("members_json") {
1474            let mj_vlu: Vec<VarLenUnicode> = mj_ds
1475                .read_raw()
1476                .map_err(|e| hdf5_err("/config/isotope_groups/members_json", e))?;
1477            snap.isotope_group_members_json =
1478                mj_vlu.iter().map(|v| v.as_str().to_string()).collect();
1479        }
1480        if let Ok(d_ds) = ig.dataset("density") {
1481            snap.isotope_group_density = d_ds
1482                .read_raw()
1483                .map_err(|e| hdf5_err("/config/isotope_groups/density", e))?;
1484        }
1485        if let Ok(en_ds) = ig.dataset("enabled") {
1486            let en_raw: Vec<u8> = en_ds
1487                .read_raw()
1488                .map_err(|e| hdf5_err("/config/isotope_groups/enabled", e))?;
1489            snap.isotope_group_enabled = en_raw.iter().map(|&v| v != 0).collect();
1490        }
1491    }
1492
1493    // Workflow mode + event params (backward-compatible: empty string triggers heuristic)
1494    snap.input_mode = read_str_attr_opt(&config, "input_mode").unwrap_or_default();
1495    snap.analysis_mode = read_str_attr_opt(&config, "analysis_mode").unwrap_or_default();
1496    snap.spatial_binning_factor = config
1497        .attr("spatial_binning_factor")
1498        .and_then(|a| a.read_scalar::<u32>())
1499        .ok()
1500        .and_then(|v| u8::try_from(v).ok());
1501
1502    if let Ok(ep) = config.group("event_params") {
1503        snap.event_n_bins = read_u32_attr(&ep, "n_bins").unwrap_or(0);
1504        snap.event_tof_min_us = read_f64_attr(&ep, "tof_min_us").unwrap_or(0.0);
1505        snap.event_tof_max_us = read_f64_attr(&ep, "tof_max_us").unwrap_or(0.0);
1506        snap.event_height = read_u32_attr(&ep, "height").unwrap_or(0);
1507        snap.event_width = read_u32_attr(&ep, "width").unwrap_or(0);
1508    }
1509
1510    // ENDF library
1511    snap.endf_library = read_str_attr(&config, "endf_library")?;
1512
1513    Ok(())
1514}
1515
1516fn read_data_links(file: &hdf5::File, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
1517    let data = file.group("data").map_err(|e| hdf5_err("open /data", e))?;
1518    snap.data_mode = read_str_attr(&data, "mode")?;
1519    snap.spectrum_unit = read_str_attr(&data, "spectrum_unit")?;
1520    snap.spectrum_kind = read_str_attr(&data, "spectrum_kind")?;
1521    snap.rebin_factor = read_u32_attr(&data, "rebin_factor")?;
1522    snap.rebin_applied = read_bool_attr(&data, "rebin_applied")?;
1523
1524    let links = data
1525        .group("links")
1526        .map_err(|e| hdf5_err("open /data/links", e))?;
1527    snap.sample_path = read_str_attr_opt(&links, "sample_path");
1528    snap.open_beam_path = read_str_attr_opt(&links, "open_beam_path");
1529    snap.spectrum_path = read_str_attr_opt(&links, "spectrum_path");
1530    snap.hdf5_path = read_str_attr_opt(&links, "hdf5_path");
1531    snap.hdf5_ob_path = read_str_attr_opt(&links, "hdf5_ob_path");
1532
1533    // Read embedded data if mode is "embedded"
1534    if snap.data_mode == "embedded" {
1535        read_embedded_data(&data, snap)?;
1536    }
1537
1538    Ok(())
1539}
1540
1541fn read_embedded_data(data_group: &hdf5::Group, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
1542    let embedded = data_group
1543        .group("embedded")
1544        .map_err(|e| hdf5_err("open /data/embedded (file claims embedded mode)", e))?;
1545
1546    if let Ok(ds) = embedded.dataset("sample") {
1547        let shape = ds.shape();
1548        if shape.len() != 3 {
1549            return Err(hdf5_err(
1550                "/data/embedded/sample",
1551                format!("expected 3D, got {}D", shape.len()),
1552            ));
1553        }
1554        let data: Vec<f64> = ds
1555            .read_raw()
1556            .map_err(|e| hdf5_err("/data/embedded/sample", e))?;
1557        snap.sample_data = Some(
1558            Array3::from_shape_vec((shape[0], shape[1], shape[2]), data)
1559                .map_err(|e| hdf5_err("/data/embedded/sample reshape", e))?,
1560        );
1561    }
1562
1563    if let Ok(ds) = embedded.dataset("open_beam") {
1564        let shape = ds.shape();
1565        if shape.len() != 3 {
1566            return Err(hdf5_err(
1567                "/data/embedded/open_beam",
1568                format!("expected 3D, got {}D", shape.len()),
1569            ));
1570        }
1571        let data: Vec<f64> = ds
1572            .read_raw()
1573            .map_err(|e| hdf5_err("/data/embedded/open_beam", e))?;
1574        snap.open_beam_data = Some(
1575            Array3::from_shape_vec((shape[0], shape[1], shape[2]), data)
1576                .map_err(|e| hdf5_err("/data/embedded/open_beam reshape", e))?,
1577        );
1578    }
1579
1580    if let Ok(ds) = embedded.dataset("spectrum") {
1581        let data: Vec<f64> = ds
1582            .read_raw()
1583            .map_err(|e| hdf5_err("/data/embedded/spectrum", e))?;
1584        snap.spectrum_values = Some(data);
1585    }
1586
1587    Ok(())
1588}
1589
1590fn read_intermediate(file: &hdf5::File, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
1591    let inter = file
1592        .group("intermediate")
1593        .map_err(|e| hdf5_err("open /intermediate", e))?;
1594
1595    if let Ok(norm_ds) = inter.dataset("normalized") {
1596        let shape = norm_ds.shape();
1597        if shape.len() == 3 {
1598            let data: Vec<f64> = norm_ds
1599                .read_raw()
1600                .map_err(|e| hdf5_err("/intermediate/normalized", e))?;
1601            snap.normalized = Some(
1602                Array3::from_shape_vec((shape[0], shape[1], shape[2]), data)
1603                    .map_err(|e| hdf5_err("/intermediate/normalized reshape", e))?,
1604            );
1605        }
1606    }
1607
1608    // D-1: Load per-bin transmission uncertainty.
1609    if let Ok(unc_ds) = inter.dataset("normalized_uncertainty") {
1610        let shape = unc_ds.shape();
1611        if shape.len() == 3 {
1612            let data: Vec<f64> = unc_ds
1613                .read_raw()
1614                .map_err(|e| hdf5_err("/intermediate/normalized_uncertainty", e))?;
1615            snap.normalized_uncertainty = Some(
1616                Array3::from_shape_vec((shape[0], shape[1], shape[2]), data)
1617                    .map_err(|e| hdf5_err("/intermediate/normalized_uncertainty reshape", e))?,
1618            );
1619        }
1620    }
1621
1622    if let Ok(e_ds) = inter.dataset("energies") {
1623        let data: Vec<f64> = e_ds
1624            .read_raw()
1625            .map_err(|e| hdf5_err("/intermediate/energies", e))?;
1626        snap.energies = Some(data);
1627    }
1628
1629    // D-20: Load dead-pixel mask (u8 → bool).
1630    if let Ok(dp_ds) = inter.dataset("dead_pixels") {
1631        let shape = dp_ds.shape();
1632        if shape.len() == 2 {
1633            let data: Vec<u8> = dp_ds
1634                .read_raw()
1635                .map_err(|e| hdf5_err("/intermediate/dead_pixels", e))?;
1636            let bools: Vec<bool> = data.iter().map(|&v| v != 0).collect();
1637            snap.dead_pixels = Some(
1638                Array2::from_shape_vec((shape[0], shape[1]), bools)
1639                    .map_err(|e| hdf5_err("/intermediate/dead_pixels reshape", e))?,
1640            );
1641        }
1642    }
1643
1644    // #646 R4 P1-1: Load the detected mask component (u8 → bool).  The
1645    // dataset is versioned; a missing or unrecognized `format_version`
1646    // leaves the field `None`, as does a dataset whose rank is not 2 —
1647    // the same shape gate as the declared-mask reader above
1648    // (declared-only restore — the documented fallback for files
1649    // predating the dataset, see DETECTED_DEAD_PIXELS_FORMAT_VERSION).
1650    if let Ok(det_ds) = inter.dataset("detected_dead_pixels") {
1651        let version = read_u32_attr_ds_opt(&det_ds, "format_version");
1652        let shape = det_ds.shape();
1653        if version == Some(DETECTED_DEAD_PIXELS_FORMAT_VERSION) && shape.len() == 2 {
1654            let data: Vec<u8> = det_ds
1655                .read_raw()
1656                .map_err(|e| hdf5_err("/intermediate/detected_dead_pixels", e))?;
1657            let bools: Vec<bool> = data.iter().map(|&v| v != 0).collect();
1658            snap.detected_dead_pixels = Some(
1659                Array2::from_shape_vec((shape[0], shape[1]), bools)
1660                    .map_err(|e| hdf5_err("/intermediate/detected_dead_pixels reshape", e))?,
1661            );
1662        }
1663    }
1664
1665    Ok(())
1666}
1667
1668fn read_results(file: &hdf5::File, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
1669    let results = file
1670        .group("results")
1671        .map_err(|e| hdf5_err("open /results", e))?;
1672
1673    // Read isotope labels first so we can use them to order density/uncertainty maps
1674    if let Ok(labels_ds) = results.dataset("result_isotopes") {
1675        let labels_vlu: Vec<VarLenUnicode> = labels_ds
1676            .read_raw()
1677            .map_err(|e| hdf5_err("/results/result_isotopes", e))?;
1678        let labels: Vec<String> = labels_vlu.iter().map(|v| v.as_str().to_string()).collect();
1679        snap.result_isotope_labels = Some(labels);
1680    }
1681
1682    // Density maps
1683    if let Ok(density_grp) = results.group("density") {
1684        let names = density_grp
1685            .member_names()
1686            .map_err(|e| hdf5_err("/results/density member_names", e))?;
1687
1688        // Order by result_isotope_labels if available, otherwise alphabetical.
1689        // Append any datasets not in labels so data isn't lost on corrupted files.
1690        let ordered: Vec<String> = if let Some(ref labels) = snap.result_isotope_labels {
1691            let mut ordered: Vec<String> = labels
1692                .iter()
1693                .filter(|l| names.contains(l))
1694                .cloned()
1695                .collect();
1696            let mut remaining: Vec<String> =
1697                names.into_iter().filter(|n| !labels.contains(n)).collect();
1698            remaining.sort();
1699            ordered.extend(remaining);
1700            ordered
1701        } else {
1702            let mut sorted = names;
1703            sorted.sort();
1704            sorted
1705        };
1706
1707        let mut maps = Vec::with_capacity(ordered.len());
1708        for name in &ordered {
1709            let ds = density_grp
1710                .dataset(name)
1711                .map_err(|e| hdf5_err(&format!("/results/density/{name}"), e))?;
1712            let shape = ds.shape();
1713            if shape.len() == 2 {
1714                let data: Vec<f64> = ds
1715                    .read_raw()
1716                    .map_err(|e| hdf5_err(&format!("/results/density/{name}"), e))?;
1717                maps.push(
1718                    Array2::from_shape_vec((shape[0], shape[1]), data)
1719                        .map_err(|e| hdf5_err(&format!("/results/density/{name} reshape"), e))?,
1720                );
1721            }
1722        }
1723        if !maps.is_empty() {
1724            snap.density_maps = Some(maps);
1725        }
1726    }
1727
1728    // Uncertainty maps
1729    if let Ok(unc_grp) = results.group("uncertainty") {
1730        let names = unc_grp
1731            .member_names()
1732            .map_err(|e| hdf5_err("/results/uncertainty member_names", e))?;
1733
1734        let ordered: Vec<String> = if let Some(ref labels) = snap.result_isotope_labels {
1735            let mut ordered: Vec<String> = labels
1736                .iter()
1737                .filter(|l| names.contains(l))
1738                .cloned()
1739                .collect();
1740            let mut remaining: Vec<String> =
1741                names.into_iter().filter(|n| !labels.contains(n)).collect();
1742            remaining.sort();
1743            ordered.extend(remaining);
1744            ordered
1745        } else {
1746            let mut sorted = names;
1747            sorted.sort();
1748            sorted
1749        };
1750
1751        let mut maps = Vec::with_capacity(ordered.len());
1752        for name in &ordered {
1753            let ds = unc_grp
1754                .dataset(name)
1755                .map_err(|e| hdf5_err(&format!("/results/uncertainty/{name}"), e))?;
1756            let shape = ds.shape();
1757            if shape.len() == 2 {
1758                let data: Vec<f64> = ds
1759                    .read_raw()
1760                    .map_err(|e| hdf5_err(&format!("/results/uncertainty/{name}"), e))?;
1761                maps.push(
1762                    Array2::from_shape_vec((shape[0], shape[1]), data).map_err(|e| {
1763                        hdf5_err(&format!("/results/uncertainty/{name} reshape"), e)
1764                    })?,
1765                );
1766            }
1767        }
1768        if !maps.is_empty() {
1769            snap.uncertainty_maps = Some(maps);
1770        }
1771    }
1772
1773    // Chi-squared map
1774    if let Ok(chi2_ds) = results.dataset("chi_squared") {
1775        let shape = chi2_ds.shape();
1776        if shape.len() == 2 {
1777            let data: Vec<f64> = chi2_ds
1778                .read_raw()
1779                .map_err(|e| hdf5_err("/results/chi_squared", e))?;
1780            snap.chi_squared_map = Some(
1781                Array2::from_shape_vec((shape[0], shape[1]), data)
1782                    .map_err(|e| hdf5_err("/results/chi_squared reshape", e))?,
1783            );
1784        }
1785    }
1786
1787    // Converged map
1788    if let Ok(conv_ds) = results.dataset("converged") {
1789        let shape = conv_ds.shape();
1790        if shape.len() == 2 {
1791            let data: Vec<u8> = conv_ds
1792                .read_raw()
1793                .map_err(|e| hdf5_err("/results/converged", e))?;
1794            snap.converged_map = Some(
1795                Array2::from_shape_vec(
1796                    (shape[0], shape[1]),
1797                    data.iter().map(|&v| v != 0).collect(),
1798                )
1799                .map_err(|e| hdf5_err("/results/converged reshape", e))?,
1800            );
1801        }
1802    }
1803
1804    // Temperature map
1805    if let Ok(t_ds) = results.dataset("temperature") {
1806        let shape = t_ds.shape();
1807        if shape.len() == 2 {
1808            let data: Vec<f64> = t_ds
1809                .read_raw()
1810                .map_err(|e| hdf5_err("/results/temperature", e))?;
1811            snap.temperature_map = Some(
1812                Array2::from_shape_vec((shape[0], shape[1]), data)
1813                    .map_err(|e| hdf5_err("/results/temperature reshape", e))?,
1814            );
1815        }
1816    }
1817
1818    // Temperature uncertainty map (optional — absent in older project files).
1819    if let Ok(tu_ds) = results.dataset("temperature_uncertainty") {
1820        let shape = tu_ds.shape();
1821        if shape.len() == 2 {
1822            let data: Vec<f64> = tu_ds
1823                .read_raw()
1824                .map_err(|e| hdf5_err("/results/temperature_uncertainty", e))?;
1825            snap.temperature_uncertainty_map = Some(
1826                Array2::from_shape_vec((shape[0], shape[1]), data)
1827                    .map_err(|e| hdf5_err("/results/temperature_uncertainty reshape", e))?,
1828            );
1829        }
1830    }
1831
1832    // D-11/D-21: Anorm map
1833    if let Ok(a_ds) = results.dataset("anorm") {
1834        let shape = a_ds.shape();
1835        if shape.len() == 2 {
1836            let data: Vec<f64> = a_ds.read_raw().map_err(|e| hdf5_err("/results/anorm", e))?;
1837            snap.anorm_map = Some(
1838                Array2::from_shape_vec((shape[0], shape[1]), data)
1839                    .map_err(|e| hdf5_err("/results/anorm reshape", e))?,
1840            );
1841        }
1842    }
1843
1844    // D-11/D-21: Background maps.  FAIL CLOSED on a partial or malformed
1845    // group (review R3): save_project writes all three datasets atomically,
1846    // so a group that exists with a missing or mis-shaped member is a
1847    // truncated / corrupted file — silently loading it as "no background"
1848    // would display a different scientific model than the one fitted.
1849    if let Ok(bg_grp) = results.group("background") {
1850        let mut maps: [Option<Array2<f64>>; 3] = [None, None, None];
1851        for (i, &label) in ["back_a", "back_b", "back_c"].iter().enumerate() {
1852            let ds = bg_grp.dataset(label).map_err(|e| {
1853                hdf5_err(
1854                    &format!(
1855                        "/results/background exists but /results/background/{label} is missing \
1856                         (truncated or corrupted project file)"
1857                    ),
1858                    e,
1859                )
1860            })?;
1861            let shape = ds.shape();
1862            if shape.len() != 2 {
1863                return Err(IoError::Hdf5Error(format!(
1864                    "/results/background/{label}: expected a 2-D map, got {} dimension(s) \
1865                     (corrupted project file)",
1866                    shape.len()
1867                )));
1868            }
1869            let data: Vec<f64> = ds
1870                .read_raw()
1871                .map_err(|e| hdf5_err(&format!("/results/background/{label}"), e))?;
1872            maps[i] = Some(
1873                Array2::from_shape_vec((shape[0], shape[1]), data)
1874                    .map_err(|e| hdf5_err(&format!("/results/background/{label} reshape"), e))?,
1875            );
1876        }
1877        snap.background_maps = Some([
1878            maps[0].take().unwrap(),
1879            maps[1].take().unwrap(),
1880            maps[2].take().unwrap(),
1881        ]);
1882    }
1883
1884    // Issue #635: multiplicative-baseline outputs.  FAIL CLOSED on
1885    // present-but-malformed data (review R3): the GUI overlay rebuilds
1886    // B(E) from these fields, so silently dropping a truncated baseline
1887    // would render a different scientific model than the one fitted —
1888    // the exact silent-model-change class the persistence fix closed.
1889    // ABSENCE stays legal (pre-#635 files, baseline-off fits).
1890    if let Ok(ds) = results.dataset("baseline_global") {
1891        let data: Vec<f64> = ds
1892            .read_raw()
1893            .map_err(|e| hdf5_err("/results/baseline_global", e))?;
1894        if data.len() != 3 {
1895            return Err(IoError::Hdf5Error(format!(
1896                "/results/baseline_global: expected 3 coefficients, got {} \
1897                 (corrupted project file)",
1898                data.len()
1899            )));
1900        }
1901        snap.baseline_global = Some([data[0], data[1], data[2]]);
1902    }
1903    if let Ok(bl_grp) = results.group("baseline") {
1904        let mut maps: [Option<Array2<f64>>; 3] = [None, None, None];
1905        for (i, &label) in ["b0", "b1", "b2"].iter().enumerate() {
1906            let ds = bl_grp.dataset(label).map_err(|e| {
1907                hdf5_err(
1908                    &format!(
1909                        "/results/baseline exists but /results/baseline/{label} is missing \
1910                         (truncated or corrupted project file)"
1911                    ),
1912                    e,
1913                )
1914            })?;
1915            let shape = ds.shape();
1916            if shape.len() != 2 {
1917                return Err(IoError::Hdf5Error(format!(
1918                    "/results/baseline/{label}: expected a 2-D map, got {} dimension(s) \
1919                     (corrupted project file)",
1920                    shape.len()
1921                )));
1922            }
1923            let data: Vec<f64> = ds
1924                .read_raw()
1925                .map_err(|e| hdf5_err(&format!("/results/baseline/{label}"), e))?;
1926            maps[i] = Some(
1927                Array2::from_shape_vec((shape[0], shape[1]), data)
1928                    .map_err(|e| hdf5_err(&format!("/results/baseline/{label} reshape"), e))?,
1929            );
1930        }
1931        snap.baseline_maps = Some([
1932            maps[0].take().unwrap(),
1933            maps[1].take().unwrap(),
1934            maps[2].take().unwrap(),
1935        ]);
1936    }
1937    snap.baseline_e_ref_ev = read_f64_attr(&results, "baseline_e_ref_ev").ok();
1938    // Coefficients without the reference energy are unreconstructable —
1939    // B(E) needs the exact E_ref the fit used.
1940    if (snap.baseline_global.is_some() || snap.baseline_maps.is_some())
1941        && snap.baseline_e_ref_ev.is_none()
1942    {
1943        return Err(IoError::Hdf5Error(
1944            "baseline coefficients are present but the baseline_e_ref_ev \
1945             attribute is missing — B(E) cannot be reconstructed (corrupted \
1946             project file)"
1947                .into(),
1948        ));
1949    }
1950
1951    // Scalar attrs
1952    if let Ok(nc) = read_u64_attr(&results, "n_converged") {
1953        snap.n_converged = Some(nc as usize);
1954    }
1955    if let Ok(nt) = read_u64_attr(&results, "n_total") {
1956        snap.n_total = Some(nt as usize);
1957    }
1958    if let Ok(nf) = read_u64_attr(&results, "n_failed") {
1959        snap.n_failed = Some(nf as usize);
1960    }
1961
1962    // Single-pixel fit results
1963    if let Ok(sf) = results.group("single_fit") {
1964        if let Ok(ds) = sf.dataset("densities") {
1965            let data: Vec<f64> = ds
1966                .read_raw()
1967                .map_err(|e| hdf5_err("/results/single_fit/densities", e))?;
1968            snap.single_fit_densities = Some(data);
1969        }
1970        if let Ok(ds) = sf.dataset("uncertainties") {
1971            let data: Vec<f64> = ds
1972                .read_raw()
1973                .map_err(|e| hdf5_err("/results/single_fit/uncertainties", e))?;
1974            snap.single_fit_uncertainties = Some(data);
1975        }
1976        snap.single_fit_chi_squared = read_f64_attr(&sf, "chi_squared").ok();
1977        snap.single_fit_temperature = read_f64_attr(&sf, "temperature_k").ok();
1978        snap.single_fit_temperature_unc = read_f64_attr(&sf, "temperature_k_unc").ok();
1979        snap.single_fit_converged = read_bool_attr(&sf, "converged").ok();
1980        snap.single_fit_iterations = read_u32_attr(&sf, "iterations").ok().map(|v| v as usize);
1981        if let (Ok(py), Ok(px)) = (read_u32_attr(&sf, "pixel_y"), read_u32_attr(&sf, "pixel_x")) {
1982            snap.single_fit_pixel = Some((py as usize, px as usize));
1983        }
1984        if let Ok(ds) = sf.dataset("isotope_labels") {
1985            let vlu: Vec<VarLenUnicode> = ds
1986                .read_raw()
1987                .map_err(|e| hdf5_err("/results/single_fit/isotope_labels", e))?;
1988            snap.single_fit_labels = Some(vlu.iter().map(|v| v.as_str().to_string()).collect());
1989        }
1990        snap.single_fit_anorm = read_f64_attr(&sf, "anorm").ok();
1991        if let Ok(ds) = sf.dataset("background") {
1992            let data: Vec<f64> = ds
1993                .read_raw()
1994                .map_err(|e| hdf5_err("/results/single_fit/background", e))?;
1995            if data.len() == 3 {
1996                snap.single_fit_background = Some([data[0], data[1], data[2]]);
1997            }
1998        }
1999        if let Ok(ds) = sf.dataset("baseline") {
2000            let data: Vec<f64> = ds
2001                .read_raw()
2002                .map_err(|e| hdf5_err("/results/single_fit/baseline", e))?;
2003            if data.len() != 3 {
2004                return Err(IoError::Hdf5Error(format!(
2005                    "/results/single_fit/baseline: expected 3 coefficients, got {} \
2006                     (corrupted project file)",
2007                    data.len()
2008                )));
2009            }
2010            snap.single_fit_baseline = Some([data[0], data[1], data[2]]);
2011        }
2012        snap.single_fit_baseline_e_ref_ev = read_f64_attr(&sf, "baseline_e_ref_ev").ok();
2013        if snap.single_fit_baseline.is_some() && snap.single_fit_baseline_e_ref_ev.is_none() {
2014            return Err(IoError::Hdf5Error(
2015                "/results/single_fit/baseline is present but its baseline_e_ref_ev \
2016                 attribute is missing — B(E) cannot be reconstructed (corrupted \
2017                 project file)"
2018                    .into(),
2019            ));
2020        }
2021    }
2022
2023    Ok(())
2024}
2025
2026fn read_endf_cache_into(file: &hdf5::File, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
2027    let cache = file
2028        .group("endf_cache")
2029        .map_err(|e| hdf5_err("open /endf_cache", e))?;
2030
2031    let names = cache
2032        .member_names()
2033        .map_err(|e| hdf5_err("/endf_cache member_names", e))?;
2034
2035    for name in &names {
2036        let iso_grp = cache
2037            .group(name)
2038            .map_err(|e| hdf5_err(&format!("/endf_cache/{name}"), e))?;
2039        let ds = iso_grp
2040            .dataset("resonance_data")
2041            .map_err(|e| hdf5_err(&format!("/endf_cache/{name}/resonance_data"), e))?;
2042        let json: VarLenUnicode = ds
2043            .read_scalar()
2044            .map_err(|e| hdf5_err(&format!("/endf_cache/{name}/resonance_data"), e))?;
2045        let rd: ResonanceData = serde_json::from_str(json.as_str())
2046            .map_err(|e| hdf5_err(&format!("deserialize /endf_cache/{name}"), e))?;
2047        snap.endf_cache.push((name.clone(), rd));
2048    }
2049
2050    Ok(())
2051}
2052
2053fn read_provenance_into(file: &hdf5::File, snap: &mut ProjectSnapshot) -> Result<(), IoError> {
2054    let prov = file
2055        .group("provenance")
2056        .map_err(|e| hdf5_err("open /provenance", e))?;
2057
2058    let ts_ds = match prov.dataset("timestamps") {
2059        Ok(ds) => ds,
2060        Err(_) => return Ok(()), // empty provenance
2061    };
2062    let timestamps: Vec<VarLenUnicode> = ts_ds
2063        .read_raw()
2064        .map_err(|e| hdf5_err("/provenance/timestamps", e))?;
2065    let kinds: Vec<VarLenUnicode> = prov
2066        .dataset("kinds")
2067        .and_then(|ds| ds.read_raw())
2068        .map_err(|e| hdf5_err("/provenance/kinds", e))?;
2069    let messages: Vec<VarLenUnicode> = prov
2070        .dataset("messages")
2071        .and_then(|ds| ds.read_raw())
2072        .map_err(|e| hdf5_err("/provenance/messages", e))?;
2073
2074    for (i, ts_vlu) in timestamps.iter().enumerate() {
2075        let ts = ts_vlu.as_str().to_string();
2076        let kind = kinds
2077            .get(i)
2078            .map_or(String::new(), |v| v.as_str().to_string());
2079        let msg = messages
2080            .get(i)
2081            .map_or(String::new(), |v| v.as_str().to_string());
2082        snap.provenance.push((ts, kind, msg));
2083    }
2084
2085    Ok(())
2086}
2087
2088// ---------------------------------------------------------------------------
2089// Tests
2090// ---------------------------------------------------------------------------
2091
2092#[cfg(test)]
2093mod tests {
2094    use super::*;
2095    use hdf5::types::VarLenUnicode;
2096    use ndarray::{Array2, Array3};
2097
2098    fn minimal_snapshot() -> ProjectSnapshot {
2099        ProjectSnapshot {
2100            schema_version: PROJECT_SCHEMA_VERSION.to_string(),
2101            created_utc: "2026-03-07T12:00:00Z".into(),
2102            software_version: "0.1.0".into(),
2103            fitting_type: "spatial".into(),
2104            data_type: "transmission".into(),
2105            flight_path_m: 15.3,
2106            delay_us: 0.0,
2107            proton_charge_sample: 1.0,
2108            proton_charge_ob: 1.0,
2109            isotope_z: vec![],
2110            isotope_a: vec![],
2111            isotope_symbol: vec![],
2112            isotope_density: vec![],
2113            isotope_enabled: vec![],
2114            isotope_group_z: vec![],
2115            isotope_group_names: vec![],
2116            isotope_group_members_json: vec![],
2117            isotope_group_density: vec![],
2118            isotope_group_enabled: vec![],
2119            input_mode: String::new(),
2120            analysis_mode: String::new(),
2121            spatial_binning_factor: None,
2122            event_n_bins: 0,
2123            event_tof_min_us: 0.0,
2124            event_tof_max_us: 0.0,
2125            event_height: 0,
2126            event_width: 0,
2127            solver_method: "lm".into(),
2128            max_iter: 20,
2129            temperature_k: 300.0,
2130            fit_temperature: false,
2131            fit_energy_scale: None,
2132            fit_energy_range: None,
2133            resolution_enabled: false,
2134            resolution_kind: "gaussian".into(),
2135            delta_t_us: Some(1.5),
2136            delta_l_m: Some(0.003),
2137            tabulated_path: None,
2138            rois: vec![],
2139            endf_library: "ENDF/B-VIII.0".into(),
2140            data_mode: "linked".into(),
2141            sample_path: Some("/data/sample".into()),
2142            open_beam_path: Some("/data/ob".into()),
2143            spectrum_path: Some("/data/spectrum.txt".into()),
2144            hdf5_path: None,
2145            hdf5_ob_path: None,
2146            spectrum_unit: "tof_us".into(),
2147            spectrum_kind: "bin_edges".into(),
2148            rebin_factor: 1,
2149            rebin_applied: false,
2150            sample_data: None,
2151            open_beam_data: None,
2152            spectrum_values: None,
2153            normalized: None,
2154            normalized_uncertainty: None,
2155            energies: None,
2156            dead_pixels: None,
2157            detected_dead_pixels: None,
2158            density_maps: None,
2159            uncertainty_maps: None,
2160            chi_squared_map: None,
2161            converged_map: None,
2162            temperature_map: None,
2163            temperature_uncertainty_map: None,
2164            n_converged: None,
2165            n_total: None,
2166            n_failed: None,
2167            result_isotope_labels: None,
2168            anorm_map: None,
2169            background_maps: None,
2170            baseline_global: None,
2171            baseline_e_ref_ev: None,
2172            baseline_maps: None,
2173            single_fit_densities: None,
2174            single_fit_uncertainties: None,
2175            single_fit_chi_squared: None,
2176            single_fit_temperature: None,
2177            single_fit_temperature_unc: None,
2178            single_fit_converged: None,
2179            single_fit_iterations: None,
2180            single_fit_pixel: None,
2181            single_fit_labels: None,
2182            single_fit_anorm: None,
2183            single_fit_background: None,
2184            single_fit_baseline: None,
2185            single_fit_baseline_e_ref_ev: None,
2186            uncertainty_is_estimated: Some(false),
2187            lm_background_enabled: None,
2188            kl_background_enabled: None,
2189            baseline_enabled: None,
2190            kl_c_ratio: None,
2191            kl_enable_polish_override: None,
2192            endf_cache: vec![],
2193            provenance: vec![],
2194        }
2195    }
2196
2197    #[test]
2198    fn test_save_minimal() {
2199        let dir = tempfile::tempdir().unwrap();
2200        let path = dir.path().join("test.nrd.h5");
2201        let snap = minimal_snapshot();
2202        save_project(&path, &snap).unwrap();
2203
2204        let file = hdf5::File::open(&path).unwrap();
2205        // Verify top-level groups exist
2206        assert!(file.group("meta").is_ok());
2207        assert!(file.group("config").is_ok());
2208        assert!(file.group("data").is_ok());
2209        assert!(file.group("intermediate").is_ok());
2210        assert!(file.group("results").is_ok());
2211        assert!(file.group("endf_cache").is_ok());
2212        assert!(file.group("provenance").is_ok());
2213
2214        // Verify meta attrs
2215        let meta = file.group("meta").unwrap();
2216        let ver: VarLenUnicode = meta.attr("version").unwrap().read_scalar().unwrap();
2217        assert_eq!(ver.as_str(), "1.0");
2218        let ft: VarLenUnicode = meta.attr("fitting_type").unwrap().read_scalar().unwrap();
2219        assert_eq!(ft.as_str(), "spatial");
2220    }
2221
2222    #[test]
2223    fn test_save_with_results() {
2224        let dir = tempfile::tempdir().unwrap();
2225        let path = dir.path().join("results.nrd.h5");
2226        let mut snap = minimal_snapshot();
2227        snap.density_maps = Some(vec![Array2::from_elem((3, 4), 0.001)]);
2228        snap.uncertainty_maps = Some(vec![Array2::from_elem((3, 4), 0.0001)]);
2229        snap.chi_squared_map = Some(Array2::from_elem((3, 4), 1.5));
2230        snap.converged_map = Some(Array2::from_elem((3, 4), true));
2231        snap.n_converged = Some(12);
2232        snap.n_total = Some(12);
2233        snap.n_failed = Some(0);
2234        snap.result_isotope_labels = Some(vec!["W-182".into()]);
2235        save_project(&path, &snap).unwrap();
2236
2237        let file = hdf5::File::open(&path).unwrap();
2238        let results = file.group("results").unwrap();
2239
2240        // Check density map
2241        let density = results.group("density").unwrap();
2242        let ds = density.dataset("W-182").unwrap();
2243        assert_eq!(ds.shape(), vec![3, 4]);
2244        let data: Vec<f64> = ds.read_raw().unwrap();
2245        assert!((data[0] - 0.001).abs() < 1e-10);
2246
2247        // Check converged map
2248        let conv_ds = results.dataset("converged").unwrap();
2249        let conv: Vec<u8> = conv_ds.read_raw().unwrap();
2250        assert_eq!(conv[0], 1);
2251
2252        // Check attrs
2253        let nc: u64 = results.attr("n_converged").unwrap().read_scalar().unwrap();
2254        assert_eq!(nc, 12);
2255        let nf: u64 = results.attr("n_failed").unwrap().read_scalar().unwrap();
2256        assert_eq!(nf, 0);
2257    }
2258
2259    #[test]
2260    fn test_save_with_intermediate() {
2261        let dir = tempfile::tempdir().unwrap();
2262        let path = dir.path().join("inter.nrd.h5");
2263        let mut snap = minimal_snapshot();
2264        snap.normalized = Some(Array3::from_elem((10, 3, 4), 0.5));
2265        snap.energies = Some(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
2266        save_project(&path, &snap).unwrap();
2267
2268        let file = hdf5::File::open(&path).unwrap();
2269        let inter = file.group("intermediate").unwrap();
2270        let norm_ds = inter.dataset("normalized").unwrap();
2271        assert_eq!(norm_ds.shape(), vec![10, 3, 4]);
2272
2273        let e_ds = inter.dataset("energies").unwrap();
2274        let e: Vec<f64> = e_ds.read_raw().unwrap();
2275        assert_eq!(e.len(), 5);
2276        assert!((e[2] - 3.0).abs() < 1e-10);
2277    }
2278
2279    #[test]
2280    fn test_save_endf_cache() {
2281        let dir = tempfile::tempdir().unwrap();
2282        let path = dir.path().join("endf.nrd.h5");
2283        let mut snap = minimal_snapshot();
2284
2285        // Create a minimal ResonanceData (empty ranges)
2286        let rd = ResonanceData {
2287            isotope: nereids_core::types::Isotope::new(74, 182).unwrap(),
2288            za: 74182,
2289            awr: 180.948,
2290            ranges: vec![],
2291        };
2292        snap.endf_cache = vec![("W-182".into(), rd)];
2293        save_project(&path, &snap).unwrap();
2294
2295        let file = hdf5::File::open(&path).unwrap();
2296        let cache = file.group("endf_cache").unwrap();
2297        let w182 = cache.group("W-182").unwrap();
2298        let ds = w182.dataset("resonance_data").unwrap();
2299        let json: VarLenUnicode = ds.read_scalar().unwrap();
2300
2301        // Round-trip: deserialize back
2302        let rd2: ResonanceData = serde_json::from_str(json.as_str()).unwrap();
2303        assert_eq!(rd2.za, 74182);
2304        assert!((rd2.awr - 180.948).abs() < 1e-6);
2305    }
2306
2307    #[test]
2308    fn test_save_provenance() {
2309        let dir = tempfile::tempdir().unwrap();
2310        let path = dir.path().join("prov.nrd.h5");
2311        let mut snap = minimal_snapshot();
2312        snap.provenance = vec![
2313            (
2314                "2026-03-07T12:00:00Z".into(),
2315                "DataLoaded".into(),
2316                "Loaded sample".into(),
2317            ),
2318            (
2319                "2026-03-07T12:01:00Z".into(),
2320                "AnalysisRun".into(),
2321                "Spatial map done".into(),
2322            ),
2323        ];
2324        save_project(&path, &snap).unwrap();
2325
2326        let file = hdf5::File::open(&path).unwrap();
2327        let prov = file.group("provenance").unwrap();
2328        let ts: Vec<VarLenUnicode> = prov.dataset("timestamps").unwrap().read_raw().unwrap();
2329        assert_eq!(ts.len(), 2);
2330        assert_eq!(ts[0].as_str(), "2026-03-07T12:00:00Z");
2331
2332        let kinds: Vec<VarLenUnicode> = prov.dataset("kinds").unwrap().read_raw().unwrap();
2333        assert_eq!(kinds[1].as_str(), "AnalysisRun");
2334    }
2335
2336    #[test]
2337    fn test_save_isotope_config() {
2338        let dir = tempfile::tempdir().unwrap();
2339        let path = dir.path().join("iso.nrd.h5");
2340        let mut snap = minimal_snapshot();
2341        snap.isotope_z = vec![74, 26];
2342        snap.isotope_a = vec![182, 56];
2343        snap.isotope_symbol = vec!["W-182".into(), "Fe-56".into()];
2344        snap.isotope_density = vec![0.001, 0.002];
2345        snap.isotope_enabled = vec![true, false];
2346        save_project(&path, &snap).unwrap();
2347
2348        let file = hdf5::File::open(&path).unwrap();
2349        let iso = file.group("config/isotopes").unwrap();
2350        let z: Vec<u32> = iso.dataset("z").unwrap().read_raw().unwrap();
2351        assert_eq!(z, vec![74, 26]);
2352        let en: Vec<u8> = iso.dataset("enabled").unwrap().read_raw().unwrap();
2353        assert_eq!(en, vec![1, 0]);
2354    }
2355
2356    #[test]
2357    fn test_save_rois() {
2358        let dir = tempfile::tempdir().unwrap();
2359        let path = dir.path().join("roi.nrd.h5");
2360        let mut snap = minimal_snapshot();
2361        snap.rois = vec![[10, 20, 30, 40], [50, 60, 70, 80]];
2362        save_project(&path, &snap).unwrap();
2363
2364        let file = hdf5::File::open(&path).unwrap();
2365        let config = file.group("config").unwrap();
2366        let ds = config.dataset("rois").unwrap();
2367        assert_eq!(ds.shape(), vec![2, 4]);
2368        let data: Vec<u64> = ds.read_raw().unwrap();
2369        assert_eq!(data, vec![10, 20, 30, 40, 50, 60, 70, 80]);
2370    }
2371
2372    #[test]
2373    fn test_save_temperature_map_present() {
2374        let dir = tempfile::tempdir().unwrap();
2375        let path = dir.path().join("temp.nrd.h5");
2376        let mut snap = minimal_snapshot();
2377        snap.temperature_map = Some(Array2::from_elem((3, 4), 295.0));
2378        snap.density_maps = Some(vec![Array2::from_elem((3, 4), 0.001)]);
2379        snap.converged_map = Some(Array2::from_elem((3, 4), true));
2380        snap.n_converged = Some(12);
2381        snap.n_total = Some(12);
2382        save_project(&path, &snap).unwrap();
2383
2384        let file = hdf5::File::open(&path).unwrap();
2385        let results = file.group("results").unwrap();
2386        let t_ds = results.dataset("temperature").unwrap();
2387        assert_eq!(t_ds.shape(), vec![3, 4]);
2388        let data: Vec<f64> = t_ds.read_raw().unwrap();
2389        assert!((data[0] - 295.0).abs() < 1e-10);
2390    }
2391
2392    #[test]
2393    fn test_save_temperature_map_absent() {
2394        let dir = tempfile::tempdir().unwrap();
2395        let path = dir.path().join("no_temp.nrd.h5");
2396        let mut snap = minimal_snapshot();
2397        snap.density_maps = Some(vec![Array2::from_elem((3, 4), 0.001)]);
2398        snap.converged_map = Some(Array2::from_elem((3, 4), true));
2399        snap.n_converged = Some(12);
2400        snap.n_total = Some(12);
2401        save_project(&path, &snap).unwrap();
2402
2403        let file = hdf5::File::open(&path).unwrap();
2404        let results = file.group("results").unwrap();
2405        assert!(results.dataset("temperature").is_err());
2406    }
2407
2408    /// Phase 4: temperature_uncertainty_map survives project round-trip.
2409    #[test]
2410    fn test_roundtrip_temperature_uncertainty_map() {
2411        let dir = tempfile::tempdir().unwrap();
2412        let path = dir.path().join("t_unc.nrd.h5");
2413        let mut snap = minimal_snapshot();
2414        snap.temperature_map = Some(Array2::from_elem((3, 4), 300.0));
2415        snap.temperature_uncertainty_map = Some(Array2::from_elem((3, 4), 5.2));
2416        snap.density_maps = Some(vec![Array2::from_elem((3, 4), 0.001)]);
2417        snap.converged_map = Some(Array2::from_elem((3, 4), true));
2418        snap.n_converged = Some(12);
2419        snap.n_total = Some(12);
2420        save_project(&path, &snap).unwrap();
2421
2422        let loaded = load_project(&path).unwrap();
2423        let tu = loaded
2424            .temperature_uncertainty_map
2425            .expect("temperature_uncertainty_map should survive round-trip");
2426        assert_eq!(tu.shape(), [3, 4]);
2427        assert!((tu[[0, 0]] - 5.2).abs() < 1e-10);
2428    }
2429
2430    /// Phase 4: older project files without temperature_uncertainty load as None.
2431    #[test]
2432    fn test_load_old_project_without_temperature_uncertainty() {
2433        let dir = tempfile::tempdir().unwrap();
2434        let path = dir.path().join("old.nrd.h5");
2435        let mut snap = minimal_snapshot();
2436        snap.temperature_map = Some(Array2::from_elem((3, 4), 300.0));
2437        // Deliberately do NOT set temperature_uncertainty_map.
2438        snap.density_maps = Some(vec![Array2::from_elem((3, 4), 0.001)]);
2439        snap.converged_map = Some(Array2::from_elem((3, 4), true));
2440        snap.n_converged = Some(12);
2441        snap.n_total = Some(12);
2442        save_project(&path, &snap).unwrap();
2443
2444        let loaded = load_project(&path).unwrap();
2445        assert!(loaded.temperature_map.is_some());
2446        // The field was None when saved, so the HDF5 dataset was not written.
2447        // On load, missing dataset → None.  This is backward-compatible.
2448        assert!(
2449            loaded.temperature_uncertainty_map.is_none(),
2450            "old project without temperature_uncertainty should load as None"
2451        );
2452    }
2453
2454    #[test]
2455    fn test_save_beamline_config() {
2456        let dir = tempfile::tempdir().unwrap();
2457        let path = dir.path().join("bl.nrd.h5");
2458        let mut snap = minimal_snapshot();
2459        snap.flight_path_m = 15.3;
2460        snap.delay_us = 42.5;
2461        save_project(&path, &snap).unwrap();
2462
2463        let file = hdf5::File::open(&path).unwrap();
2464        let bl = file.group("config/beamline").unwrap();
2465        let fp: f64 = bl.attr("flight_path_m").unwrap().read_scalar().unwrap();
2466        let delay: f64 = bl.attr("delay_us").unwrap().read_scalar().unwrap();
2467        assert!((fp - 15.3).abs() < 1e-10);
2468        assert!((delay - 42.5).abs() < 1e-10);
2469    }
2470
2471    // -----------------------------------------------------------------------
2472    // Round-trip tests
2473    // -----------------------------------------------------------------------
2474
2475    #[test]
2476    fn test_roundtrip_minimal() {
2477        let dir = tempfile::tempdir().unwrap();
2478        let path = dir.path().join("rt.nrd.h5");
2479        let snap = minimal_snapshot();
2480        save_project(&path, &snap).unwrap();
2481        let loaded = load_project(&path).unwrap();
2482
2483        assert_eq!(loaded.schema_version, snap.schema_version);
2484        assert_eq!(loaded.created_utc, snap.created_utc);
2485        assert_eq!(loaded.software_version, snap.software_version);
2486        assert_eq!(loaded.fitting_type, snap.fitting_type);
2487        assert_eq!(loaded.data_type, snap.data_type);
2488        assert!((loaded.flight_path_m - snap.flight_path_m).abs() < 1e-10);
2489        assert!((loaded.delay_us - snap.delay_us).abs() < 1e-10);
2490        assert!((loaded.proton_charge_sample - snap.proton_charge_sample).abs() < 1e-10);
2491        assert!((loaded.proton_charge_ob - snap.proton_charge_ob).abs() < 1e-10);
2492        assert_eq!(loaded.solver_method, snap.solver_method);
2493        assert_eq!(loaded.max_iter, snap.max_iter);
2494        assert!((loaded.temperature_k - snap.temperature_k).abs() < 1e-10);
2495        assert_eq!(loaded.fit_temperature, snap.fit_temperature);
2496        assert_eq!(loaded.resolution_enabled, snap.resolution_enabled);
2497        assert_eq!(loaded.resolution_kind, snap.resolution_kind);
2498        assert_eq!(loaded.endf_library, snap.endf_library);
2499        assert_eq!(loaded.data_mode, snap.data_mode);
2500        assert_eq!(loaded.sample_path, snap.sample_path);
2501        assert_eq!(loaded.open_beam_path, snap.open_beam_path);
2502        assert_eq!(loaded.spectrum_path, snap.spectrum_path);
2503        assert_eq!(loaded.hdf5_path, snap.hdf5_path);
2504        assert_eq!(loaded.spectrum_unit, snap.spectrum_unit);
2505        assert_eq!(loaded.spectrum_kind, snap.spectrum_kind);
2506        assert_eq!(loaded.rebin_factor, snap.rebin_factor);
2507        assert_eq!(loaded.rebin_applied, snap.rebin_applied);
2508    }
2509
2510    #[test]
2511    fn test_roundtrip_with_results() {
2512        let dir = tempfile::tempdir().unwrap();
2513        let path = dir.path().join("rt_results.nrd.h5");
2514        let mut snap = minimal_snapshot();
2515        snap.density_maps = Some(vec![
2516            Array2::from_elem((3, 4), 0.001),
2517            Array2::from_elem((3, 4), 0.002),
2518        ]);
2519        snap.uncertainty_maps = Some(vec![
2520            Array2::from_elem((3, 4), 0.0001),
2521            Array2::from_elem((3, 4), 0.0002),
2522        ]);
2523        snap.chi_squared_map = Some(Array2::from_elem((3, 4), 1.5));
2524        snap.converged_map = Some(Array2::from_elem((3, 4), true));
2525        snap.temperature_map = Some(Array2::from_elem((3, 4), 295.0));
2526        snap.n_converged = Some(12);
2527        snap.n_total = Some(12);
2528        snap.n_failed = Some(1);
2529        snap.result_isotope_labels = Some(vec!["W-182".into(), "Fe-56".into()]);
2530        save_project(&path, &snap).unwrap();
2531        let loaded = load_project(&path).unwrap();
2532
2533        let dm = loaded.density_maps.unwrap();
2534        assert_eq!(dm.len(), 2);
2535        assert!((dm[0][[0, 0]] - 0.001).abs() < 1e-10);
2536        assert!((dm[1][[0, 0]] - 0.002).abs() < 1e-10);
2537
2538        let um = loaded.uncertainty_maps.unwrap();
2539        assert_eq!(um.len(), 2);
2540
2541        let chi2 = loaded.chi_squared_map.unwrap();
2542        assert!((chi2[[0, 0]] - 1.5).abs() < 1e-10);
2543
2544        let conv = loaded.converged_map.unwrap();
2545        assert!(conv[[0, 0]]);
2546
2547        let temp = loaded.temperature_map.unwrap();
2548        assert!((temp[[0, 0]] - 295.0).abs() < 1e-10);
2549
2550        assert_eq!(loaded.n_converged, Some(12));
2551        assert_eq!(loaded.n_total, Some(12));
2552        assert_eq!(loaded.n_failed, Some(1));
2553        assert_eq!(
2554            loaded.result_isotope_labels,
2555            Some(vec!["W-182".into(), "Fe-56".into()])
2556        );
2557    }
2558
2559    #[test]
2560    fn test_roundtrip_with_intermediate() {
2561        let dir = tempfile::tempdir().unwrap();
2562        let path = dir.path().join("rt_inter.nrd.h5");
2563        let mut snap = minimal_snapshot();
2564        snap.normalized = Some(Array3::from_elem((10, 3, 4), 0.5));
2565        snap.energies = Some(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
2566        save_project(&path, &snap).unwrap();
2567        let loaded = load_project(&path).unwrap();
2568
2569        let norm = loaded.normalized.unwrap();
2570        assert_eq!(norm.shape(), &[10, 3, 4]);
2571        assert!((norm[[0, 0, 0]] - 0.5).abs() < 1e-10);
2572
2573        let en = loaded.energies.unwrap();
2574        assert_eq!(en.len(), 5);
2575        assert!((en[2] - 3.0).abs() < 1e-10);
2576    }
2577
2578    /// #646 R4 P1-1: the declared and detected mask components round-trip
2579    /// as independent datasets — neither leaks into the other.
2580    #[test]
2581    fn test_roundtrip_detected_dead_pixels_independent_of_declared() {
2582        let dir = tempfile::tempdir().unwrap();
2583        let path = dir.path().join("rt_detected.nrd.h5");
2584        let mut snap = minimal_snapshot();
2585        let mut declared = Array2::from_elem((3, 4), false);
2586        declared[[0, 0]] = true;
2587        let mut detected = Array2::from_elem((3, 4), false);
2588        detected[[2, 3]] = true;
2589        snap.dead_pixels = Some(declared.clone());
2590        snap.detected_dead_pixels = Some(detected.clone());
2591        save_project(&path, &snap).unwrap();
2592        let loaded = load_project(&path).unwrap();
2593
2594        assert_eq!(loaded.dead_pixels.unwrap(), declared);
2595        assert_eq!(loaded.detected_dead_pixels.unwrap(), detected);
2596    }
2597
2598    /// #646 R4 P1-1 backward compat: a file written without the detected
2599    /// dataset (any pre-R4 file, or a session with no detection) loads
2600    /// with `detected_dead_pixels = None` — the declared-only fallback.
2601    #[test]
2602    fn test_load_without_detected_dataset_is_declared_only() {
2603        let dir = tempfile::tempdir().unwrap();
2604        let path = dir.path().join("no_detected.nrd.h5");
2605        let mut snap = minimal_snapshot();
2606        let mut declared = Array2::from_elem((2, 2), false);
2607        declared[[1, 1]] = true;
2608        snap.dead_pixels = Some(declared.clone());
2609        save_project(&path, &snap).unwrap();
2610        let loaded = load_project(&path).unwrap();
2611
2612        assert_eq!(loaded.dead_pixels.unwrap(), declared);
2613        assert!(loaded.detected_dead_pixels.is_none());
2614    }
2615
2616    /// #646 R4 P1-1 forward compat: a detected dataset whose
2617    /// `format_version` is unrecognized is ignored (declared-only
2618    /// restore), never misread under the current format's semantics.
2619    #[test]
2620    fn test_detected_dead_pixels_future_format_version_ignored() {
2621        let dir = tempfile::tempdir().unwrap();
2622        let path = dir.path().join("future_detected.nrd.h5");
2623        let mut snap = minimal_snapshot();
2624        let mut detected = Array2::from_elem((2, 2), false);
2625        detected[[0, 1]] = true;
2626        snap.detected_dead_pixels = Some(detected);
2627        save_project(&path, &snap).unwrap();
2628
2629        // Bump the dataset's format_version past the supported one.
2630        {
2631            let file = hdf5::File::open_rw(&path).unwrap();
2632            let ds = file.dataset("intermediate/detected_dead_pixels").unwrap();
2633            ds.attr("format_version")
2634                .unwrap()
2635                .write_scalar(&(DETECTED_DEAD_PIXELS_FORMAT_VERSION + 1))
2636                .unwrap();
2637        }
2638
2639        let loaded = load_project(&path).unwrap();
2640        assert!(loaded.detected_dead_pixels.is_none());
2641    }
2642
2643    #[test]
2644    fn test_roundtrip_endf_cache() {
2645        let dir = tempfile::tempdir().unwrap();
2646        let path = dir.path().join("rt_endf.nrd.h5");
2647        let mut snap = minimal_snapshot();
2648        let rd = ResonanceData {
2649            isotope: nereids_core::types::Isotope::new(74, 182).unwrap(),
2650            za: 74182,
2651            awr: 180.948,
2652            ranges: vec![],
2653        };
2654        snap.endf_cache = vec![("W-182".into(), rd)];
2655        save_project(&path, &snap).unwrap();
2656        let loaded = load_project(&path).unwrap();
2657
2658        assert_eq!(loaded.endf_cache.len(), 1);
2659        assert_eq!(loaded.endf_cache[0].0, "W-182");
2660        assert_eq!(loaded.endf_cache[0].1.za, 74182);
2661        assert!((loaded.endf_cache[0].1.awr - 180.948).abs() < 1e-6);
2662    }
2663
2664    #[test]
2665    fn test_roundtrip_provenance() {
2666        let dir = tempfile::tempdir().unwrap();
2667        let path = dir.path().join("rt_prov.nrd.h5");
2668        let mut snap = minimal_snapshot();
2669        snap.provenance = vec![
2670            (
2671                "2026-03-07 12:00:00 UTC".into(),
2672                "DataLoaded".into(),
2673                "Loaded sample".into(),
2674            ),
2675            (
2676                "2026-03-07 12:01:00 UTC".into(),
2677                "AnalysisRun".into(),
2678                "Spatial map done".into(),
2679            ),
2680        ];
2681        save_project(&path, &snap).unwrap();
2682        let loaded = load_project(&path).unwrap();
2683
2684        assert_eq!(loaded.provenance.len(), 2);
2685        assert_eq!(loaded.provenance[0].0, "2026-03-07 12:00:00 UTC");
2686        assert_eq!(loaded.provenance[0].1, "DataLoaded");
2687        assert_eq!(loaded.provenance[0].2, "Loaded sample");
2688        assert_eq!(loaded.provenance[1].1, "AnalysisRun");
2689    }
2690
2691    #[test]
2692    fn test_roundtrip_isotope_config() {
2693        let dir = tempfile::tempdir().unwrap();
2694        let path = dir.path().join("rt_iso.nrd.h5");
2695        let mut snap = minimal_snapshot();
2696        snap.isotope_z = vec![74, 26];
2697        snap.isotope_a = vec![182, 56];
2698        snap.isotope_symbol = vec!["W-182".into(), "Fe-56".into()];
2699        snap.isotope_density = vec![0.001, 0.002];
2700        snap.isotope_enabled = vec![true, false];
2701        save_project(&path, &snap).unwrap();
2702        let loaded = load_project(&path).unwrap();
2703
2704        assert_eq!(loaded.isotope_z, vec![74, 26]);
2705        assert_eq!(loaded.isotope_a, vec![182, 56]);
2706        assert_eq!(loaded.isotope_symbol, vec!["W-182", "Fe-56"]);
2707        assert!((loaded.isotope_density[0] - 0.001).abs() < 1e-10);
2708        assert_eq!(loaded.isotope_enabled, vec![true, false]);
2709    }
2710
2711    #[test]
2712    fn test_roundtrip_rois() {
2713        let dir = tempfile::tempdir().unwrap();
2714        let path = dir.path().join("rt_rois.nrd.h5");
2715        let mut snap = minimal_snapshot();
2716        snap.rois = vec![[10, 20, 30, 40], [50, 60, 70, 80]];
2717        save_project(&path, &snap).unwrap();
2718        let loaded = load_project(&path).unwrap();
2719
2720        assert_eq!(loaded.rois.len(), 2);
2721        assert_eq!(loaded.rois[0], [10, 20, 30, 40]);
2722        assert_eq!(loaded.rois[1], [50, 60, 70, 80]);
2723    }
2724
2725    #[test]
2726    fn test_load_missing_version() {
2727        let dir = tempfile::tempdir().unwrap();
2728        let path = dir.path().join("bad.h5");
2729        // Create an HDF5 file with no /meta group
2730        hdf5::File::create(&path).unwrap();
2731        let result = load_project(&path);
2732        assert!(result.is_err());
2733        let err = result.unwrap_err().to_string();
2734        assert!(
2735            err.contains("schema") || err.contains("meta") || err.contains("version"),
2736            "Error should mention missing schema: {err}"
2737        );
2738    }
2739
2740    #[test]
2741    fn test_load_future_version() {
2742        let dir = tempfile::tempdir().unwrap();
2743        let path = dir.path().join("future.nrd.h5");
2744        let mut snap = minimal_snapshot();
2745        snap.schema_version = "99.0".into();
2746        save_project(&path, &snap).unwrap();
2747        let loaded = load_project(&path).unwrap();
2748        assert_eq!(loaded.schema_version, "99.0");
2749    }
2750
2751    #[test]
2752    fn test_roundtrip_empty_isotopes() {
2753        let dir = tempfile::tempdir().unwrap();
2754        let path = dir.path().join("rt_empty_iso.nrd.h5");
2755        let snap = minimal_snapshot(); // has empty isotope arrays
2756        save_project(&path, &snap).unwrap();
2757        let loaded = load_project(&path).unwrap();
2758        assert!(loaded.isotope_z.is_empty());
2759        assert!(loaded.isotope_a.is_empty());
2760        assert!(loaded.isotope_symbol.is_empty());
2761        assert!(loaded.isotope_density.is_empty());
2762        assert!(loaded.isotope_enabled.is_empty());
2763    }
2764
2765    // -- embedded mode tests --
2766
2767    #[test]
2768    fn test_roundtrip_embedded_sample_only() {
2769        let dir = tempfile::tempdir().unwrap();
2770        let path = dir.path().join("embed_sample.nrd.h5");
2771        let snap = minimal_snapshot();
2772        let sample = Array3::from_shape_fn((5, 3, 4), |(t, y, x)| (t * 12 + y * 4 + x) as f64);
2773        let spectrum = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
2774        let emb = EmbeddedData {
2775            sample: Some(&sample),
2776            open_beam: None,
2777            spectrum: Some(&spectrum),
2778        };
2779        save_project_with_data(&path, &snap, Some(&emb)).unwrap();
2780        let loaded = load_project(&path).unwrap();
2781
2782        assert_eq!(loaded.data_mode, "embedded");
2783        let loaded_sample = loaded.sample_data.unwrap();
2784        assert_eq!(loaded_sample.shape(), [5, 3, 4]);
2785        assert_eq!(loaded_sample, sample);
2786        assert!(loaded.open_beam_data.is_none());
2787        assert_eq!(loaded.spectrum_values.unwrap(), spectrum);
2788    }
2789
2790    #[test]
2791    fn test_roundtrip_embedded_full() {
2792        let dir = tempfile::tempdir().unwrap();
2793        let path = dir.path().join("embed_full.nrd.h5");
2794        let snap = minimal_snapshot();
2795        let sample = Array3::from_shape_fn((4, 2, 3), |(t, y, x)| (t * 6 + y * 3 + x) as f64 + 0.5);
2796        let ob = Array3::from_shape_fn((4, 2, 3), |(t, y, x)| (t * 6 + y * 3 + x) as f64 * 2.0);
2797        let spectrum = vec![10.0, 20.0, 30.0, 40.0, 50.0];
2798        let emb = EmbeddedData {
2799            sample: Some(&sample),
2800            open_beam: Some(&ob),
2801            spectrum: Some(&spectrum),
2802        };
2803        save_project_with_data(&path, &snap, Some(&emb)).unwrap();
2804        let loaded = load_project(&path).unwrap();
2805
2806        assert_eq!(loaded.data_mode, "embedded");
2807        assert_eq!(loaded.sample_data.unwrap(), sample);
2808        assert_eq!(loaded.open_beam_data.unwrap(), ob);
2809        assert_eq!(loaded.spectrum_values.unwrap(), spectrum);
2810    }
2811
2812    #[test]
2813    fn test_roundtrip_embedded_preserves_links() {
2814        let dir = tempfile::tempdir().unwrap();
2815        let path = dir.path().join("embed_links.nrd.h5");
2816        let snap = minimal_snapshot();
2817        let sample = Array3::from_elem((2, 2, 2), 1.0);
2818        let emb = EmbeddedData {
2819            sample: Some(&sample),
2820            open_beam: None,
2821            spectrum: None,
2822        };
2823        save_project_with_data(&path, &snap, Some(&emb)).unwrap();
2824        let loaded = load_project(&path).unwrap();
2825
2826        // Links should still be present from the snapshot
2827        assert_eq!(loaded.sample_path, Some("/data/sample".into()));
2828        assert_eq!(loaded.open_beam_path, Some("/data/ob".into()));
2829        assert_eq!(loaded.spectrum_path, Some("/data/spectrum.txt".into()));
2830    }
2831
2832    #[test]
2833    fn test_embedded_file_size() {
2834        let dir = tempfile::tempdir().unwrap();
2835        let path = dir.path().join("embed_size.nrd.h5");
2836        let snap = minimal_snapshot();
2837        // 100 frames × 10 × 10 = 10,000 f64 values = 80 KB raw
2838        let sample = Array3::from_elem((100, 10, 10), 42.0);
2839        let emb = EmbeddedData {
2840            sample: Some(&sample),
2841            open_beam: None,
2842            spectrum: None,
2843        };
2844        save_project_with_data(&path, &snap, Some(&emb)).unwrap();
2845        let file_size = std::fs::metadata(&path).unwrap().len();
2846        let raw_size = 100 * 10 * 10 * 8; // 80,000 bytes
2847        // Compressed file should be smaller than raw data (gzip on uniform data)
2848        assert!(
2849            file_size < raw_size,
2850            "File size {file_size} should be < raw {raw_size}"
2851        );
2852    }
2853
2854    #[test]
2855    fn test_estimate_embedded_size() {
2856        let sample = Array3::from_elem((10, 5, 4), 1.0); // 200 elements
2857        let ob = Array3::from_elem((10, 5, 4), 2.0); // 200 elements
2858        let spectrum = vec![1.0; 11]; // 11 elements
2859        let (raw, compressed) = estimate_embedded_size(Some(&sample), Some(&ob), Some(&spectrum));
2860        // 200 + 200 + 11 = 411 f64 values × 8 bytes = 3288 bytes
2861        assert_eq!(raw, 411 * 8);
2862        assert!(compressed < raw);
2863        assert!(compressed > 0);
2864    }
2865
2866    #[test]
2867    fn test_linked_mode_no_embedded_group() {
2868        let dir = tempfile::tempdir().unwrap();
2869        let path = dir.path().join("linked_no_embed.nrd.h5");
2870        let snap = minimal_snapshot();
2871        save_project(&path, &snap).unwrap(); // linked mode, no embedded data
2872
2873        // Verify /data/embedded group does NOT exist
2874        let file = hdf5::File::open(&path).unwrap();
2875        let data = file.group("data").unwrap();
2876        assert!(
2877            data.group("embedded").is_err(),
2878            "Linked-mode file should not have /data/embedded group"
2879        );
2880    }
2881
2882    #[test]
2883    fn test_embedded_missing_group_errors() {
2884        // Save a linked-mode file, then patch data_mode to "embedded" and
2885        // verify that loading returns an error (missing /data/embedded group).
2886        let dir = tempfile::tempdir().unwrap();
2887        let path = dir.path().join("missing_embedded.nrd.h5");
2888        let snap = minimal_snapshot();
2889        save_project(&path, &snap).unwrap();
2890
2891        // Overwrite /data/mode attribute to "embedded" without adding an embedded group
2892        {
2893            let file = hdf5::File::open_rw(&path).unwrap();
2894            let data = file.group("data").unwrap();
2895            // Delete existing mode attribute, then recreate as "embedded"
2896            data.delete_attr("mode").unwrap();
2897            let val: hdf5::types::VarLenUnicode = "embedded".parse().unwrap();
2898            data.new_attr::<hdf5::types::VarLenUnicode>()
2899                .shape(())
2900                .create("mode")
2901                .and_then(|a| a.write_scalar(&val))
2902                .unwrap();
2903        }
2904
2905        let err = load_project(&path);
2906        assert!(err.is_err(), "Should error when embedded group is missing");
2907        let msg = format!("{}", err.unwrap_err());
2908        assert!(
2909            msg.contains("embedded"),
2910            "Error should mention 'embedded': {msg}"
2911        );
2912    }
2913
2914    #[test]
2915    fn test_embedded_wrong_dimensionality_errors() {
2916        // Save a valid embedded file, then replace sample with a 1D dataset.
2917        let dir = tempfile::tempdir().unwrap();
2918        let path = dir.path().join("wrong_dim.nrd.h5");
2919        let sample = Array3::from_elem((2, 3, 4), 1.0);
2920        let spectrum = vec![1.0, 2.0];
2921        let mut snap = minimal_snapshot();
2922        snap.data_mode = "embedded".into();
2923        let emb = EmbeddedData {
2924            sample: Some(&sample),
2925            open_beam: None,
2926            spectrum: Some(&spectrum),
2927        };
2928        save_project_with_data(&path, &snap, Some(&emb)).unwrap();
2929
2930        // Replace /data/embedded/sample with a 1D dataset
2931        {
2932            let file = hdf5::File::open_rw(&path).unwrap();
2933            let embedded = file.group("data/embedded").unwrap();
2934            let _ = embedded.unlink("sample");
2935            embedded
2936                .new_dataset::<f64>()
2937                .shape([24])
2938                .create("sample")
2939                .unwrap()
2940                .write_raw(&[0.0_f64; 24])
2941                .unwrap();
2942        }
2943
2944        let err = load_project(&path);
2945        assert!(err.is_err(), "Should error on non-3D sample dataset");
2946        let msg = format!("{}", err.unwrap_err());
2947        assert!(
2948            msg.contains("expected 3D"),
2949            "Error should mention dimensionality: {msg}"
2950        );
2951    }
2952
2953    #[test]
2954    fn test_roundtrip_single_fit() {
2955        let dir = tempfile::tempdir().unwrap();
2956        let path = dir.path().join("single_fit.nrd.h5");
2957        let mut snap = minimal_snapshot();
2958        snap.single_fit_densities = Some(vec![0.001, 0.002]);
2959        snap.single_fit_uncertainties = Some(vec![1e-5, 2e-5]);
2960        snap.single_fit_chi_squared = Some(1.23);
2961        snap.single_fit_temperature = Some(296.0);
2962        snap.single_fit_temperature_unc = Some(5.0);
2963        snap.single_fit_converged = Some(true);
2964        snap.single_fit_iterations = Some(42);
2965        snap.single_fit_pixel = Some((10, 20));
2966        snap.single_fit_labels = Some(vec!["U-238".into(), "Fe-56".into()]);
2967        save_project(&path, &snap).unwrap();
2968
2969        let loaded = load_project(&path).unwrap();
2970        assert_eq!(
2971            loaded.single_fit_densities.as_deref(),
2972            Some([0.001, 0.002].as_slice())
2973        );
2974        assert_eq!(
2975            loaded.single_fit_uncertainties.as_deref(),
2976            Some([1e-5, 2e-5].as_slice())
2977        );
2978        assert!((loaded.single_fit_chi_squared.unwrap() - 1.23).abs() < 1e-10);
2979        assert!((loaded.single_fit_temperature.unwrap() - 296.0).abs() < 1e-10);
2980        assert!((loaded.single_fit_temperature_unc.unwrap() - 5.0).abs() < 1e-10);
2981        assert_eq!(loaded.single_fit_converged, Some(true));
2982        assert_eq!(loaded.single_fit_iterations, Some(42));
2983        assert_eq!(loaded.single_fit_pixel, Some((10, 20)));
2984        let expected_labels: Vec<String> = vec!["U-238".into(), "Fe-56".into()];
2985        assert_eq!(loaded.single_fit_labels, Some(expected_labels));
2986    }
2987
2988    /// Issue #635 (review R1 P1): baseline outputs must survive
2989    /// save/load — the GUI overlay rebuilds B(E) from them, so dropping
2990    /// them silently renders a different scientific model on reload.
2991    #[test]
2992    fn test_roundtrip_baseline_fields() {
2993        let dir = tempfile::tempdir().unwrap();
2994        let path = dir.path().join("baseline.nrd.h5");
2995        let mut snap = minimal_snapshot();
2996        snap.baseline_global = Some([1.02, -0.03, 0.01]);
2997        snap.baseline_e_ref_ev = Some(3.3166247903554);
2998        snap.baseline_maps = Some([
2999            Array2::from_elem((2, 2), 1.02),
3000            Array2::from_elem((2, 2), -0.03),
3001            Array2::from_elem((2, 2), 0.01),
3002        ]);
3003        snap.single_fit_densities = Some(vec![0.002]);
3004        snap.single_fit_baseline = Some([1.019, -0.029, 0.011]);
3005        snap.single_fit_baseline_e_ref_ev = Some(3.3166247903554);
3006        // The flag that PRODUCED these results must round-trip too (R4):
3007        // arrays without the configuration mean a reloaded project refits
3008        // a silently different model.
3009        snap.baseline_enabled = Some(true);
3010        save_project(&path, &snap).unwrap();
3011
3012        let loaded = load_project(&path).unwrap();
3013        assert_eq!(loaded.baseline_enabled, Some(true));
3014        assert_eq!(loaded.baseline_global, Some([1.02, -0.03, 0.01]));
3015        assert!((loaded.baseline_e_ref_ev.unwrap() - 3.3166247903554).abs() < 1e-12);
3016        let maps = loaded.baseline_maps.as_ref().expect("baseline maps");
3017        assert_eq!(maps[0][[0, 0]], 1.02);
3018        assert_eq!(maps[1][[1, 1]], -0.03);
3019        assert_eq!(maps[2][[0, 1]], 0.01);
3020        assert_eq!(loaded.single_fit_baseline, Some([1.019, -0.029, 0.011]));
3021        assert!((loaded.single_fit_baseline_e_ref_ev.unwrap() - 3.3166247903554).abs() < 1e-12);
3022
3023        // Absence round-trips as None (pre-#635 files and baseline-off fits).
3024        let path2 = dir.path().join("no_baseline.nrd.h5");
3025        let snap2 = minimal_snapshot();
3026        save_project(&path2, &snap2).unwrap();
3027        let loaded2 = load_project(&path2).unwrap();
3028        assert!(loaded2.baseline_global.is_none());
3029        assert!(loaded2.baseline_e_ref_ev.is_none());
3030        assert!(loaded2.baseline_maps.is_none());
3031        assert!(loaded2.single_fit_baseline.is_none());
3032        assert!(loaded2.single_fit_baseline_e_ref_ev.is_none());
3033        assert!(
3034            loaded2.baseline_enabled.is_none(),
3035            "pre-#635 files read as None"
3036        );
3037    }
3038
3039    /// Review R3: present-but-malformed baseline data must FAIL CLOSED —
3040    /// a truncated project file silently loading as "no baseline" would
3041    /// display a different scientific model than the one fitted.  The
3042    /// corrupted fixtures here are built by hand with the hdf5 API (NOT by
3043    /// save_project) so this cannot be circular with the writer.
3044    #[test]
3045    fn test_malformed_baseline_fails_closed() {
3046        let dir = tempfile::tempdir().unwrap();
3047
3048        // (a) /results/baseline group with only b0 (b1/b2 missing).
3049        let path = dir.path().join("partial_maps.nrd.h5");
3050        save_project(&path, &minimal_snapshot()).unwrap();
3051        {
3052            let f = hdf5::File::open_rw(&path).unwrap();
3053            let results = f.group("results").unwrap();
3054            let bl = results.create_group("baseline").unwrap();
3055            bl.new_dataset::<f64>()
3056                .shape([2, 2])
3057                .create("b0")
3058                .and_then(|ds| ds.write_raw(&[1.0f64; 4]))
3059                .unwrap();
3060        }
3061        let err = load_project(&path).expect_err("partial baseline group must fail closed");
3062        assert!(
3063            err.to_string().contains("truncated or corrupted"),
3064            "got: {err}"
3065        );
3066
3067        // (b) baseline_global with the wrong length.
3068        let path = dir.path().join("bad_len.nrd.h5");
3069        save_project(&path, &minimal_snapshot()).unwrap();
3070        {
3071            let f = hdf5::File::open_rw(&path).unwrap();
3072            let results = f.group("results").unwrap();
3073            results
3074                .new_dataset::<f64>()
3075                .shape([2])
3076                .create("baseline_global")
3077                .and_then(|ds| ds.write_raw(&[1.0f64, 0.0]))
3078                .unwrap();
3079        }
3080        let err = load_project(&path).expect_err("2-element baseline_global must fail closed");
3081        assert!(
3082            err.to_string().contains("expected 3 coefficients"),
3083            "got: {err}"
3084        );
3085
3086        // (c) coefficients present but the reference energy attribute
3087        // missing: B(E) is unreconstructable.
3088        let path = dir.path().join("no_eref.nrd.h5");
3089        save_project(&path, &minimal_snapshot()).unwrap();
3090        {
3091            let f = hdf5::File::open_rw(&path).unwrap();
3092            let results = f.group("results").unwrap();
3093            results
3094                .new_dataset::<f64>()
3095                .shape([3])
3096                .create("baseline_global")
3097                .and_then(|ds| ds.write_raw(&[1.02f64, -0.03, 0.01]))
3098                .unwrap();
3099        }
3100        let err = load_project(&path).expect_err("baseline without E_ref must fail closed");
3101        assert!(err.to_string().contains("baseline_e_ref_ev"), "got: {err}");
3102
3103        // (d) same-shape sibling: a partial /results/background group
3104        // (present but missing back_b/back_c) fails closed too.
3105        let path = dir.path().join("partial_bg.nrd.h5");
3106        save_project(&path, &minimal_snapshot()).unwrap();
3107        {
3108            let f = hdf5::File::open_rw(&path).unwrap();
3109            let results = f.group("results").unwrap();
3110            let bg = results.create_group("background").unwrap();
3111            bg.new_dataset::<f64>()
3112                .shape([2, 2])
3113                .create("back_a")
3114                .and_then(|ds| ds.write_raw(&[0.0f64; 4]))
3115                .unwrap();
3116        }
3117        let err = load_project(&path).expect_err("partial background group must fail closed");
3118        assert!(
3119            err.to_string().contains("truncated or corrupted"),
3120            "got: {err}"
3121        );
3122    }
3123
3124    #[test]
3125    fn test_roundtrip_no_single_fit() {
3126        let dir = tempfile::tempdir().unwrap();
3127        let path = dir.path().join("no_single_fit.nrd.h5");
3128        let snap = minimal_snapshot();
3129        save_project(&path, &snap).unwrap();
3130
3131        let loaded = load_project(&path).unwrap();
3132        assert!(loaded.single_fit_densities.is_none());
3133        assert!(loaded.single_fit_pixel.is_none());
3134        assert!(loaded.single_fit_labels.is_none());
3135    }
3136
3137    #[test]
3138    fn test_roundtrip_isotope_groups() {
3139        let dir = tempfile::tempdir().unwrap();
3140        let path = dir.path().join("rt_groups.nrd.h5");
3141        let mut snap = minimal_snapshot();
3142
3143        snap.isotope_group_z = vec![72, 74];
3144        snap.isotope_group_names = vec!["Hf (nat)".into(), "W (nat)".into()];
3145        snap.isotope_group_density = vec![0.001, 0.0005];
3146        snap.isotope_group_enabled = vec![true, false];
3147        snap.isotope_group_members_json = vec![
3148            r#"[{"a":177,"symbol":"Hf-177","ratio":0.186},{"a":178,"symbol":"Hf-178","ratio":0.273}]"#.into(),
3149            r#"[{"a":182,"symbol":"W-182","ratio":0.265}]"#.into(),
3150        ];
3151
3152        save_project(&path, &snap).unwrap();
3153        let loaded = load_project(&path).unwrap();
3154
3155        assert_eq!(loaded.isotope_group_z, vec![72, 74]);
3156        assert_eq!(loaded.isotope_group_names, vec!["Hf (nat)", "W (nat)"]);
3157        assert!((loaded.isotope_group_density[0] - 0.001).abs() < 1e-10);
3158        assert!((loaded.isotope_group_density[1] - 0.0005).abs() < 1e-10);
3159        assert_eq!(loaded.isotope_group_enabled, vec![true, false]);
3160        assert_eq!(loaded.isotope_group_members_json.len(), 2);
3161
3162        // Parse first group's JSON and verify members
3163        let members: Vec<serde_json::Value> =
3164            serde_json::from_str(&loaded.isotope_group_members_json[0]).unwrap();
3165        assert_eq!(members.len(), 2);
3166        assert_eq!(members[0]["a"].as_u64().unwrap(), 177);
3167        assert_eq!(members[0]["symbol"].as_str().unwrap(), "Hf-177");
3168        assert!((members[0]["ratio"].as_f64().unwrap() - 0.186).abs() < 1e-10);
3169        assert_eq!(members[1]["a"].as_u64().unwrap(), 178);
3170        assert_eq!(members[1]["symbol"].as_str().unwrap(), "Hf-178");
3171        assert!((members[1]["ratio"].as_f64().unwrap() - 0.273).abs() < 1e-10);
3172    }
3173
3174    #[test]
3175    fn test_roundtrip_workflow_mode_and_event_params() {
3176        let dir = tempfile::tempdir().unwrap();
3177        let path = dir.path().join("rt_workflow.nrd.h5");
3178        let mut snap = minimal_snapshot();
3179
3180        snap.input_mode = "hdf5_event".into();
3181        snap.analysis_mode = "spatial_binning".into();
3182        snap.spatial_binning_factor = Some(4);
3183        snap.event_n_bins = 500;
3184        snap.event_tof_min_us = 10.0;
3185        snap.event_tof_max_us = 30000.0;
3186        snap.event_height = 512;
3187        snap.event_width = 512;
3188
3189        save_project(&path, &snap).unwrap();
3190        let loaded = load_project(&path).unwrap();
3191
3192        assert_eq!(loaded.input_mode, "hdf5_event");
3193        assert_eq!(loaded.analysis_mode, "spatial_binning");
3194        assert_eq!(loaded.spatial_binning_factor, Some(4));
3195        assert_eq!(loaded.event_n_bins, 500);
3196        assert!((loaded.event_tof_min_us - 10.0).abs() < 1e-10);
3197        assert!((loaded.event_tof_max_us - 30000.0).abs() < 1e-10);
3198        assert_eq!(loaded.event_height, 512);
3199        assert_eq!(loaded.event_width, 512);
3200    }
3201}