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