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