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