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