Skip to main content

nereids_io/
export.rs

1//! Export spatial mapping results to TIFF, HDF5, and Markdown formats.
2
3use std::path::Path;
4
5use ndarray::Array2;
6
7use crate::error::IoError;
8
9/// Escape a string for use in a Markdown table cell.
10fn escape_md_cell(s: &str) -> String {
11    s.replace('|', "\\|").replace('\n', " ")
12}
13
14/// Export a single density map as a 32-bit float TIFF file.
15///
16/// Each pixel stores the density value (atoms/barn) as f32.  We use
17/// 32-bit rather than 64-bit floats because most image viewers
18/// (Preview, GIMP, ImageJ, Fiji) cannot open 64-bit float TIFFs.
19/// The f64→f32 conversion preserves roughly 7 significant digits of
20/// precision, which is sufficient for typical density values.
21pub fn export_density_tiff(path: &Path, data: &Array2<f64>, label: &str) -> Result<(), IoError> {
22    export_map_tiff(path, data, &format!("{label}_density"))
23}
24
25/// Export a single 2D map as a 32-bit float TIFF file.
26///
27/// The file is named `{name}.tiff` inside `path`.
28pub fn export_map_tiff(path: &Path, data: &Array2<f64>, name: &str) -> Result<(), IoError> {
29    let filename = path.join(format!("{name}.tiff"));
30    let file = std::fs::File::create(&filename)
31        .map_err(|e| IoError::TiffEncode(format!("cannot create {}: {e}", filename.display())))?;
32    let mut encoder =
33        tiff::encoder::TiffEncoder::new(file).map_err(|e| IoError::TiffEncode(e.to_string()))?;
34
35    let (height, width) = (data.shape()[0] as u32, data.shape()[1] as u32);
36
37    // tiff encoder expects a contiguous row-major slice; cast to f32
38    // for broad viewer compatibility.
39    let pixels: Vec<f32> = data.iter().map(|&v| v as f32).collect();
40    encoder
41        .write_image::<tiff::encoder::colortype::Gray32Float>(width, height, &pixels)
42        .map_err(|e| IoError::TiffEncode(e.to_string()))?;
43
44    Ok(())
45}
46
47/// Export all spatial mapping results to a single HDF5 file.
48///
49/// Layout:
50/// - `/density/{label}` — density map for each isotope
51/// - `/uncertainty/{label}` — uncertainty map for each isotope
52/// - `/chi_squared` — reduced chi-squared map
53/// - `/converged` — boolean convergence map (stored as u8: 0/1)
54/// - `/temperature` — fitted temperature map in Kelvin (when temperature fitting was enabled)
55#[cfg(feature = "hdf5")]
56pub fn export_results_hdf5(
57    path: &Path,
58    density_maps: &[Array2<f64>],
59    uncertainty_maps: &[Array2<f64>],
60    chi_squared_map: &Array2<f64>,
61    converged_map: &Array2<bool>,
62    labels: &[String],
63    temperature_map: Option<&Array2<f64>>,
64) -> Result<(), IoError> {
65    let file = hdf5::File::create(path).map_err(|e| IoError::Hdf5Error(format!("create: {e}")))?;
66
67    // Density maps
68    let density_group = file
69        .create_group("density")
70        .map_err(|e| IoError::Hdf5Error(format!("create group /density: {e}")))?;
71    for (i, map) in density_maps.iter().enumerate() {
72        let name = labels
73            .get(i)
74            .map_or_else(|| format!("isotope_{i}"), |s| s.clone());
75        let shape = [map.shape()[0], map.shape()[1]];
76        let data: Vec<f64> = map.iter().copied().collect();
77        density_group
78            .new_dataset::<f64>()
79            .shape(shape)
80            .create(name.as_str())
81            .and_then(|ds| ds.write_raw(&data))
82            .map_err(|e| IoError::Hdf5Error(format!("write /density/{name}: {e}")))?;
83    }
84
85    // Uncertainty maps
86    let unc_group = file
87        .create_group("uncertainty")
88        .map_err(|e| IoError::Hdf5Error(format!("create group /uncertainty: {e}")))?;
89    for (i, map) in uncertainty_maps.iter().enumerate() {
90        let name = labels
91            .get(i)
92            .map_or_else(|| format!("isotope_{i}"), |s| s.clone());
93        let shape = [map.shape()[0], map.shape()[1]];
94        let data: Vec<f64> = map.iter().copied().collect();
95        unc_group
96            .new_dataset::<f64>()
97            .shape(shape)
98            .create(name.as_str())
99            .and_then(|ds| ds.write_raw(&data))
100            .map_err(|e| IoError::Hdf5Error(format!("write /uncertainty/{name}: {e}")))?;
101    }
102
103    // Chi-squared map
104    {
105        let shape = [chi_squared_map.shape()[0], chi_squared_map.shape()[1]];
106        let data: Vec<f64> = chi_squared_map.iter().copied().collect();
107        file.new_dataset::<f64>()
108            .shape(shape)
109            .create("chi_squared")
110            .and_then(|ds| ds.write_raw(&data))
111            .map_err(|e| IoError::Hdf5Error(format!("write /chi_squared: {e}")))?;
112    }
113
114    // Converged map (stored as u8)
115    {
116        let shape = [converged_map.shape()[0], converged_map.shape()[1]];
117        let data: Vec<u8> = converged_map.iter().map(|&b| u8::from(b)).collect();
118        file.new_dataset::<u8>()
119            .shape(shape)
120            .create("converged")
121            .and_then(|ds| ds.write_raw(&data))
122            .map_err(|e| IoError::Hdf5Error(format!("write /converged: {e}")))?;
123    }
124
125    // Temperature map (when temperature fitting was enabled)
126    if let Some(t_map) = temperature_map {
127        let shape = [t_map.shape()[0], t_map.shape()[1]];
128        let data: Vec<f64> = t_map.iter().copied().collect();
129        file.new_dataset::<f64>()
130            .shape(shape)
131            .create("temperature")
132            .and_then(|ds| ds.write_raw(&data))
133            .map_err(|e| IoError::Hdf5Error(format!("write /temperature: {e}")))?;
134    }
135
136    Ok(())
137}
138
139/// Export a Markdown summary report of the spatial mapping results.
140pub fn export_markdown_report(
141    path: &Path,
142    labels: &[String],
143    density_maps: &[Array2<f64>],
144    converged_map: &Array2<bool>,
145    n_converged: usize,
146    n_total: usize,
147    provenance: &[(String, String)],
148) -> Result<(), IoError> {
149    let mut report = String::new();
150    report.push_str("# NEREIDS Spatial Mapping Report\n\n");
151
152    // Convergence summary
153    let pct = if n_total > 0 {
154        100.0 * n_converged as f64 / n_total as f64
155    } else {
156        0.0
157    };
158    report.push_str(&format!(
159        "## Convergence\n\n- Converged: {n_converged} / {n_total} ({pct:.1}%)\n\n"
160    ));
161
162    // Per-isotope density statistics
163    report.push_str("## Per-Isotope Density Statistics\n\n");
164    report.push_str("| Isotope | Mean Density (atoms/barn) | Std Dev |\n");
165    report.push_str("|---------|--------------------------|----------|\n");
166    for (i, map) in density_maps.iter().enumerate() {
167        let label = escape_md_cell(labels.get(i).map_or("unknown", |s| s.as_str()));
168        let conv_vals: Vec<f64> = map
169            .iter()
170            .zip(converged_map.iter())
171            .filter(|&(_, &conv)| conv)
172            .map(|(&d, _)| d)
173            .filter(|d| d.is_finite())
174            .collect();
175        if conv_vals.is_empty() {
176            report.push_str(&format!("| {label} | N/A | N/A |\n"));
177        } else {
178            let mean: f64 = conv_vals.iter().sum::<f64>() / conv_vals.len() as f64;
179            let variance: f64 =
180                conv_vals.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / conv_vals.len() as f64;
181            let std_dev = variance.sqrt();
182            report.push_str(&format!("| {label} | {mean:.6e} | {std_dev:.6e} |\n"));
183        }
184    }
185    report.push('\n');
186
187    // Provenance log
188    if !provenance.is_empty() {
189        report.push_str("## Provenance Log\n\n");
190        report.push_str("| Time | Event |\n");
191        report.push_str("|------|-------|\n");
192        for (timestamp, message) in provenance {
193            let ts = escape_md_cell(timestamp);
194            let msg = escape_md_cell(message);
195            report.push_str(&format!("| {ts} | {msg} |\n"));
196        }
197        report.push('\n');
198    }
199
200    std::fs::write(path, report).map_err(|e| {
201        IoError::WriteError(format!("cannot write report to {}: {e}", path.display()))
202    })?;
203
204    Ok(())
205}