Skip to main content

nucleide_material/
xml.rs

1//! Materials XML export.
2//!
3//! Emits `<material>` elements carrying a `name` attribute, a `<density>`
4//! child with `value`/`units` attributes (units default to `g/cm3`), and one
5//! `<nuclide name="U235" wo="..."/>` child per component. Weight fractions
6//! are written through the `wo` attribute; atom fractions would use `ao`.
7//! Nuclides are emitted in ascending nucid order and subnormal values are
8//! clamped to zero before writing.
9
10use std::io::Write;
11
12use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
13use quick_xml::Writer;
14
15use crate::{Error, Material};
16
17/// Smallest positive normal f64; smaller magnitudes are clamped to zero so
18/// downstream readers never see a subnormal.
19const SMALLEST_NORMAL: f64 = 2.225_073_858_507_201_4e-308;
20
21/// Default density units.
22pub const DEFAULT_DENSITY_UNITS: &str = "g/cm3";
23
24/// Format a float the way Python's `str(float)` would, with subnormals
25/// clamped to zero.
26fn fmt_num(v: f64) -> String {
27    let v = if v != 0.0 && v.abs() < SMALLEST_NORMAL {
28        0.0
29    } else {
30        v
31    };
32    let s = format!("{v}");
33    if v.is_finite() && !s.contains(['.', 'e', 'E']) {
34        format!("{s}.0")
35    } else {
36        s
37    }
38}
39
40impl Material {
41    /// Serialize this material as a `<material>` XML fragment.
42    ///
43    /// Components are written as weight fractions (`wo` attributes). The
44    /// density and its units are taken from the arguments rather than from
45    /// [`Material::density`], matching the free-standing export style.
46    pub fn to_xml(&self, name: &str, density: f64, units: &str) -> crate::Result<String> {
47        let mut writer = Writer::new_with_indent(Vec::<u8>::new(), b' ', 2);
48        write_material(&mut writer, self, name, density, units)?;
49        Ok(String::from_utf8_lossy(&writer.into_inner()).into_owned())
50    }
51}
52
53fn write_material<W: Write>(
54    writer: &mut Writer<W>,
55    mat: &Material,
56    name: &str,
57    density: f64,
58    units: &str,
59) -> crate::Result<()> {
60    let fractions = mat.weight_fractions()?;
61
62    let mut root = BytesStart::new("material");
63    root.push_attribute(("name", name));
64    writer.write_event(Event::Start(root))?;
65
66    let mut den = BytesStart::new("density");
67    let value = fmt_num(density);
68    den.push_attribute(("value", value.as_str()));
69    den.push_attribute(("units", units));
70    writer.write_event(Event::Empty(den))?;
71
72    for (id, wf) in fractions {
73        let mut nuc = BytesStart::new("nuclide");
74        let nuc_name = id.to_name();
75        let wo = fmt_num(wf);
76        nuc.push_attribute(("name", nuc_name.as_str()));
77        nuc.push_attribute(("wo", wo.as_str()));
78        writer.write_event(Event::Empty(nuc))?;
79    }
80
81    writer.write_event(Event::End(BytesEnd::new("material")))?;
82    Ok(())
83}
84
85/// A `<materials>` document bundling named [`Material`]s, optionally pointing
86/// at a cross-sections file via the root `cross_sections` attribute — the
87/// container shape of a materials collection document.
88#[derive(Debug, Clone, Default)]
89pub struct MaterialsDoc {
90    cross_sections: Option<String>,
91    entries: Vec<(String, Material)>,
92}
93
94impl MaterialsDoc {
95    /// An empty document.
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Set the `cross_sections` attribute on the root element.
101    pub fn cross_sections(mut self, path: impl Into<String>) -> Self {
102        self.cross_sections = Some(path.into());
103        self
104    }
105
106    /// Append a named material.
107    pub fn push(mut self, name: impl Into<String>, material: Material) -> Self {
108        self.entries.push((name.into(), material));
109        self
110    }
111
112    /// Number of materials in the document.
113    pub fn len(&self) -> usize {
114        self.entries.len()
115    }
116
117    /// True when the document holds no materials.
118    pub fn is_empty(&self) -> bool {
119        self.entries.is_empty()
120    }
121
122    /// Serialize the full `<materials>` document, including the XML
123    /// declaration. Each material uses its stored density (in
124    /// `DEFAULT_DENSITY_UNITS`); missing densities are an error.
125    pub fn to_xml(&self) -> crate::Result<String> {
126        let mut writer = Writer::new_with_indent(Vec::<u8>::new(), b' ', 2);
127        writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
128
129        let mut root = BytesStart::new("materials");
130        if let Some(path) = &self.cross_sections {
131            root.push_attribute(("cross_sections", path.as_str()));
132        }
133        writer.write_event(Event::Start(root))?;
134
135        for (name, mat) in &self.entries {
136            let rho = mat.density().ok_or(Error::MissingDensity)?;
137            write_material(&mut writer, mat, name, rho, DEFAULT_DENSITY_UNITS)?;
138        }
139
140        writer.write_event(Event::End(BytesEnd::new("materials")))?;
141        Ok(String::from_utf8_lossy(&writer.into_inner()).into_owned())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use std::collections::BTreeMap;
148
149    use quick_xml::events::Event;
150    use quick_xml::Reader;
151
152    use super::*;
153
154    fn id(name: &str) -> nucleide_nuclei::NuclideId {
155        nucleide_nuclei::NuclideId::from_name(name).unwrap()
156    }
157
158    fn uranium() -> Material {
159        let mut mat = Material::new();
160        mat.add_nuclide(id("U235"), 19.0);
161        mat.add_nuclide(id("U238"), 1.0);
162        mat
163    }
164
165    /// Collect (tag, attributes) pairs from an XML document.
166    fn elements(xml: &str) -> Vec<(String, BTreeMap<String, String>)> {
167        let mut reader = Reader::from_str(xml);
168        reader.config_mut().trim_text(true);
169        let mut out = Vec::new();
170        let mut event = reader.read_event().unwrap();
171        while event != Event::Eof {
172            if let Event::Empty(bs) | Event::Start(bs) = &event {
173                let attrs: BTreeMap<String, String> = bs
174                    .attributes()
175                    .map(|a| {
176                        let a = a.unwrap();
177                        (
178                            String::from_utf8_lossy(a.key.as_ref()).into_owned(),
179                            a.decode_and_unescape_value(reader.decoder())
180                                .unwrap()
181                                .into_owned(),
182                        )
183                    })
184                    .collect();
185                out.push((
186                    String::from_utf8_lossy(bs.name().as_ref()).into_owned(),
187                    attrs,
188                ));
189            }
190            event = reader.read_event().unwrap();
191        }
192        out
193    }
194
195    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
196        pairs
197            .iter()
198            .map(|&(k, v)| (k.to_string(), v.to_string()))
199            .collect()
200    }
201
202    #[test]
203    fn fragment_shape() {
204        let xml = uranium().to_xml("uo2", 19.1, "g/cm3").unwrap();
205        assert_eq!(
206            xml,
207            "<material name=\"uo2\">\n  \
208             <density value=\"19.1\" units=\"g/cm3\"/>\n  \
209             <nuclide name=\"U235\" wo=\"0.95\"/>\n  \
210             <nuclide name=\"U238\" wo=\"0.05\"/>\n\
211             </material>"
212        );
213    }
214
215    #[test]
216    fn fragment_attributes_are_well_formed() {
217        let xml = uranium().to_xml("u", 10.0, "g/cm3").unwrap();
218        let elems = elements(&xml);
219
220        assert_eq!(elems[0], ("material".to_string(), map(&[("name", "u")])));
221        assert_eq!(
222            elems[1],
223            (
224                "density".to_string(),
225                map(&[("value", "10.0"), ("units", "g/cm3")])
226            )
227        );
228        for (tag, attrs) in &elems[2..] {
229            assert_eq!(tag, "nuclide");
230            assert_eq!(attrs.len(), 2);
231            assert!(attrs.contains_key("name"));
232            assert!(attrs.contains_key("wo"), "weight fraction attr missing");
233        }
234    }
235
236    #[test]
237    fn fragment_of_empty_material_errors() {
238        let err = Material::new().to_xml("void", 1.0, "g/cm3").unwrap_err();
239        assert!(matches!(err, Error::Degenerate));
240    }
241
242    #[test]
243    fn subnormal_values_are_clamped_to_zero() {
244        assert_eq!(fmt_num(1e-320), "0.0");
245        assert_eq!(fmt_num(-0.0), "-0.0");
246        assert_eq!(fmt_num(1.0), "1.0");
247        assert_eq!(fmt_num(0.95), "0.95");
248        assert_eq!(fmt_num(f64::INFINITY), "inf");
249    }
250
251    #[test]
252    fn materials_doc_emits_cross_sections_root() {
253        let mut water = Material::new();
254        water.add_nuclide(id("H1"), 2.0);
255        water.set_density(Some(0.998));
256
257        let doc = MaterialsDoc::new()
258            .cross_sections("/data/cross_sections.xml")
259            .push("water", water);
260
261        let xml = doc.to_xml().unwrap();
262        assert_eq!(
263            xml,
264            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
265             <materials cross_sections=\"/data/cross_sections.xml\">\n  \
266             <material name=\"water\">\n    \
267             <density value=\"0.998\" units=\"g/cm3\"/>\n    \
268             <nuclide name=\"H1\" wo=\"1.0\"/>\n  \
269             </material>\n\
270             </materials>"
271        );
272    }
273
274    #[test]
275    fn materials_doc_requires_density_per_material() {
276        let bare = uranium(); // no density set
277        let err = MaterialsDoc::new().push("u", bare).to_xml().unwrap_err();
278        assert!(matches!(err, Error::MissingDensity));
279    }
280}