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