1use std::io::Write;
11
12use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event};
13use quick_xml::Writer;
14
15use crate::{Error, Material};
16
17const SMALLEST_NORMAL: f64 = 2.225_073_858_507_201_4e-308;
20
21pub const DEFAULT_DENSITY_UNITS: &str = "g/cm3";
23
24fn 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 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#[derive(Debug, Clone, Default)]
89pub struct MaterialsDoc {
90 cross_sections: Option<String>,
91 entries: Vec<(String, Material)>,
92}
93
94impl MaterialsDoc {
95 pub fn new() -> Self {
97 Self::default()
98 }
99
100 pub fn cross_sections(mut self, path: impl Into<String>) -> Self {
102 self.cross_sections = Some(path.into());
103 self
104 }
105
106 pub fn push(mut self, name: impl Into<String>, material: Material) -> Self {
108 self.entries.push((name.into(), material));
109 self
110 }
111
112 pub fn len(&self) -> usize {
114 self.entries.len()
115 }
116
117 pub fn is_empty(&self) -> bool {
119 self.entries.is_empty()
120 }
121
122 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 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(); let err = MaterialsDoc::new().push("u", bare).to_xml().unwrap_err();
278 assert!(matches!(err, Error::MissingDensity));
279 }
280}