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