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