Skip to main content

nereids_io/
project.rs

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