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