1use std::collections::BTreeMap;
30use std::sync::OnceLock;
31
32use nucleide_nuclei::{element_z, NuclideId};
33use thiserror::Error;
34
35use crate::Material;
36pub type FormulaResult<T> = std::result::Result<T, FormulaError>;
38
39#[derive(Debug, Error)]
45pub enum FormulaError {
46 #[error("formula syntax error at byte {pos}: {message}")]
49 ParseError {
50 pos: usize,
52 message: String,
54 },
55 #[error("unknown element symbol `{0}`")]
57 UnknownElement(String),
58 #[error("no natural abundance data for element Z={0}")]
60 NoAbundanceData(u32),
61 #[error(transparent)]
63 Core(#[from] crate::Error),
64}
65
66struct Parser<'a> {
80 src: &'a [u8],
81 pos: usize,
82}
83
84impl Parser<'_> {
85 fn parse_error(&self, pos: usize, message: impl Into<String>) -> FormulaError {
86 FormulaError::ParseError {
87 pos,
88 message: message.into(),
89 }
90 }
91
92 fn error_here(&self, message: impl Into<String>) -> FormulaError {
93 self.parse_error(self.pos, message)
94 }
95
96 fn peek(&self) -> Option<u8> {
97 self.src.get(self.pos).copied()
98 }
99
100 fn at_hyphen_dot(&self) -> bool {
103 match self.peek() {
104 Some(b'.') => true,
105 Some(0xC2) => self.src.get(self.pos + 1) == Some(&0xB7),
106 _ => false,
107 }
108 }
109
110 fn advance_over_separator(&mut self) {
111 self.pos += if self.src[self.pos] == b'.' { 1 } else { 2 };
112 }
113
114 fn take_count(&mut self) -> FormulaResult<Option<f64>> {
116 let start = self.pos;
117 while self.peek().is_some_and(|c| c.is_ascii_digit()) {
118 self.pos += 1;
119 }
120 if start == self.pos {
121 return Ok(None);
122 }
123 let text = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or_default();
124 text.parse::<u64>()
125 .map(|n| Some(n as f64))
126 .map_err(|_| self.parse_error(start, "count too large"))
127 }
128
129 fn take_element(&mut self) -> FormulaResult<(u32, f64)> {
132 let start = self.pos;
133 self.pos += 1;
134 let two_letter = self
136 .src
137 .get(start..start + 2)
138 .filter(|bytes| bytes[1].is_ascii_lowercase())
139 .and_then(|bytes| std::str::from_utf8(bytes).ok());
140 let one_letter = std::str::from_utf8(&self.src[start..start + 1]).ok();
141 let (z, matched_len) = match two_letter.and_then(element_z) {
142 Some(z) => (Some(z), 2),
143 None => (one_letter.and_then(element_z), 1),
144 };
145 let z = z.ok_or_else(|| {
146 let candidate = two_letter.unwrap_or_else(|| one_letter.unwrap_or("?"));
147 FormulaError::UnknownElement(candidate.to_string())
148 })?;
149 self.pos = start + matched_len;
150 let count = self.take_count()?.unwrap_or(1.0);
151 Ok((z, count))
152 }
153
154 fn take_units(&mut self, out: &mut BTreeMap<u32, f64>, scale: f64) -> FormulaResult<()> {
157 while let Some(c) = self.peek() {
158 match c {
159 b')' | b'.' => break,
160 0xC2 if self.at_hyphen_dot() => break,
161 b'(' => {
162 self.pos += 1;
163 let mut inner = BTreeMap::new();
164 self.take_units(&mut inner, 1.0)?;
165 if self.peek() != Some(b')') {
166 return Err(self.error_here("unbalanced parenthesis: expected `)`"));
167 }
168 self.pos += 1;
169 let mult = self.take_count()?.unwrap_or(1.0);
170 for (z, count) in inner {
171 *out.entry(z).or_insert(0.0) += count * mult * scale;
172 }
173 }
174 b'A'..=b'Z' => {
175 let (z, count) = self.take_element()?;
176 if count > 0.0 {
177 *out.entry(z).or_insert(0.0) += count * scale;
178 }
179 }
180 b'0'..=b'9' => {
181 return Err(self.error_here("count without a preceding element"));
182 }
183 _ => {
184 let ch = std::str::from_utf8(&self.src[self.pos..])
185 .unwrap_or("?")
186 .chars()
187 .next()
188 .unwrap_or('?');
189 return Err(self.error_here(format!("unexpected character `{ch}`")));
190 }
191 }
192 }
193 Ok(())
194 }
195}
196
197pub fn parse_formula(formula: &str) -> FormulaResult<Vec<(u32, f64)>> {
210 let trimmed = formula.trim();
211 let mut p = Parser {
212 src: trimmed.as_bytes(),
213 pos: 0,
214 };
215 let mut acc = BTreeMap::new();
216 let mut first = true;
217 while p.pos < trimmed.len() {
218 if !first {
219 if !p.at_hyphen_dot() {
220 if p.peek() == Some(b')') {
221 return Err(p.error_here("unbalanced parenthesis: unexpected `)`"));
222 }
223 return Err(p.error_here("expected `.` or `·` hydrate separator"));
224 }
225 p.advance_over_separator();
226 }
227 if p.peek().is_some_and(|c| c.is_ascii_digit()) {
228 if first {
229 return Err(p.parse_error(p.pos, "formula must not begin with a digit"));
230 }
231 let mult = p.take_count()?.unwrap_or(1.0);
232 p.take_units(&mut acc, mult)?;
233 } else {
234 p.take_units(&mut acc, 1.0)?;
235 }
236 first = false;
237 }
238 if first {
239 return Err(FormulaError::ParseError {
240 pos: 0,
241 message: "empty formula".to_string(),
242 });
243 }
244 Ok(acc.into_iter().collect())
245}
246
247pub trait AbundanceProvider {
256 fn natural_isotopes(&self, z: u32) -> Option<Vec<(NuclideId, f64)>>;
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
266pub struct NoAbundances;
267
268impl AbundanceProvider for NoAbundances {
269 fn natural_isotopes(&self, _z: u32) -> Option<Vec<(NuclideId, f64)>> {
270 None
271 }
272}
273
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
277pub struct NaturalAbundances;
278
279impl NaturalAbundances {
280 fn groups() -> &'static BTreeMap<u32, Vec<(NuclideId, f64)>> {
282 static GROUPS: OnceLock<BTreeMap<u32, Vec<(NuclideId, f64)>>> = OnceLock::new();
283 GROUPS.get_or_init(|| {
284 let mut groups: BTreeMap<u32, Vec<(NuclideId, f64)>> = BTreeMap::new();
285 for (&nucid, &frac) in nucleide_nuclei::data::abundance_table() {
286 if frac > 0.0 {
287 let id = NuclideId::from_nucid(nucid);
288 groups.entry(id.z()).or_default().push((id, frac));
289 }
290 }
291 groups
292 })
293 }
294}
295
296impl AbundanceProvider for NaturalAbundances {
297 fn natural_isotopes(&self, z: u32) -> Option<Vec<(NuclideId, f64)>> {
298 Self::groups().get(&z).filter(|v| !v.is_empty()).cloned()
299 }
300}
301
302fn is_elemental(id: NuclideId) -> bool {
310 id.a() == 0 && id.state() == 0
311}
312
313fn ground_mass(masses: &impl crate::MassProvider, id: NuclideId) -> Option<f64> {
317 masses
318 .mass(id.nucid())
319 .or_else(|| masses.mass(id.nucid() - id.state()))
320}
321
322impl Material {
323 pub fn from_formula(
333 formula: &str,
334 masses: &impl crate::MassProvider,
335 abundances: &impl AbundanceProvider,
336 density: Option<f64>,
337 ) -> FormulaResult<Self> {
338 let elements = parse_formula(formula)?;
339 let mut atoms = Vec::new();
340 for &(z, count) in &elements {
341 let isotopes = abundances
342 .natural_isotopes(z)
343 .ok_or(FormulaError::NoAbundanceData(z))?;
344 let total: f64 = isotopes.iter().map(|(_, x)| x).sum();
345 if total <= 0.0 {
346 return Err(FormulaError::NoAbundanceData(z));
347 }
348 for (id, frac) in isotopes {
349 atoms.push((id, count * frac / total));
350 }
351 }
352 Ok(Material::from_atom_frac(&atoms, masses, density)?)
353 }
354
355 pub fn expand_elements(
372 &mut self,
373 masses: &impl crate::MassProvider,
374 abundances: &impl AbundanceProvider,
375 ) -> FormulaResult<()> {
376 let mut expanded = BTreeMap::new();
377 for (&id, &grams) in &self.comp {
378 if !is_elemental(id) {
379 expanded.insert(id, grams);
380 continue;
381 }
382 let z = id.z();
383 let isotopes = abundances
384 .natural_isotopes(z)
385 .ok_or(FormulaError::NoAbundanceData(z))?;
386 let total: f64 = isotopes.iter().map(|(_, x)| x).sum();
387 if total <= 0.0 {
388 return Err(FormulaError::NoAbundanceData(z));
389 }
390 let mean_mass = isotopes
392 .iter()
393 .map(|&(iso, x)| {
394 ground_mass(masses, iso)
395 .ok_or(crate::Error::MissingMass(iso))
396 .map(|m| x / total * m)
397 })
398 .sum::<crate::Result<f64>>()
399 .map_err(FormulaError::from)?;
400 for (iso, x) in isotopes {
401 let m = ground_mass(masses, iso).expect("checked by mean_mass loop above");
402 expanded.insert(iso, grams * (x / total) * m / mean_mass);
403 }
404 }
405 self.comp = expanded;
406 Ok(())
407 }
408
409 pub fn collapse_elements(&self) -> Self {
415 let mut comp = BTreeMap::new();
416 for (&id, &grams) in &self.comp {
417 let key = if is_elemental(id) {
418 id
419 } else {
420 NuclideId::from_nucid(id.z() * 10_000_000)
421 };
422 *comp.entry(key).or_insert(0.0) += grams;
423 }
424 let mut out = Material::new();
425 out.comp = comp;
426 out.set_density(self.density());
427 out.set_metadata(self.metadata().cloned());
428 out
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::Ame2020;
436
437 fn counts(formula: &str) -> Vec<(u32, f64)> {
438 parse_formula(formula).unwrap()
439 }
440
441 fn assert_counts(formula: &str, expected: &[(u32, f64)]) {
442 assert_eq!(counts(formula), expected.to_vec(), "for `{formula}`");
443 }
444
445 #[test]
446 fn parses_water_and_glucose() {
447 assert_counts("H2O", &[(1, 2.0), (8, 1.0)]);
448 assert_counts("C6H12O6", &[(1, 12.0), (6, 6.0), (8, 6.0)]);
449 }
450
451 #[test]
452 fn parses_grouped_and_nested_formulas() {
453 assert_counts("Ca(OH)2", &[(1, 2.0), (8, 2.0), (20, 1.0)]);
455 assert_counts("Fe2(SO4)3", &[(8, 12.0), (16, 3.0), (26, 2.0)]);
457 assert_counts("Mg(NO2)2", &[(7, 2.0), (8, 4.0), (12, 1.0)]);
459 assert_counts("U((C)3)2", &[(6, 6.0), (92, 1.0)]);
460 }
461
462 #[test]
463 fn parses_chained_groups_as_single_elements() {
464 assert_counts("CH3(CH2)6CH3", &[(1, 18.0), (6, 8.0)]);
466 }
467
468 #[test]
469 fn parses_multi_digit_counts_and_hydrates() {
470 assert_counts("C12H22O11", &[(1, 22.0), (6, 12.0), (8, 11.0)]);
471 let dot = counts("CuSO4·5H2O");
472 let ascii = counts("CuSO4.5H2O");
473 assert_eq!(dot, ascii);
474 assert_eq!(dot, vec![(1, 10.0), (8, 9.0), (16, 1.0), (29, 1.0)]);
475 assert_counts("H2O.2H2O", &[(1, 6.0), (8, 3.0)]);
477 }
478
479 #[test]
480 fn tolerates_surrounding_whitespace_only() {
481 assert_eq!(counts(" H2O "), counts("H2O"));
482 }
483
484 #[test]
485 fn rejects_unbalanced_parentheses() {
486 let err = parse_formula("(H2O").unwrap_err();
487 assert!(
488 matches!(err, FormulaError::ParseError { pos: 4, .. }),
489 "{err}"
490 );
491 let err = parse_formula("H2O)").unwrap_err();
492 assert!(matches!(err, FormulaError::ParseError { .. }), "{err}");
493 }
494
495 #[test]
496 fn rejects_unknown_symbol() {
497 match parse_formula("Xx2O").unwrap_err() {
498 FormulaError::UnknownElement(s) => assert_eq!(s, "Xx"),
499 other => panic!("{other:?}"),
500 }
501 match parse_formula("Q").unwrap_err() {
502 FormulaError::UnknownElement(s) => assert_eq!(s, "Q"),
503 other => panic!("{other:?}"),
504 }
505 }
506
507 #[test]
508 fn rejects_leading_digit_empty_and_stray_chars() {
509 let err = parse_formula("2H2O").unwrap_err();
510 assert!(
511 matches!(err, FormulaError::ParseError { pos: 0, .. }),
512 "{err}"
513 );
514 assert!(matches!(
515 parse_formula("").unwrap_err(),
516 FormulaError::ParseError { .. }
517 ));
518 let err = parse_formula("H2 O").unwrap_err();
519 assert!(
520 matches!(err, FormulaError::ParseError { pos: 2, .. }),
521 "{err}"
522 );
523 let err = parse_formula("H1O-1").unwrap_err();
524 assert!(matches!(err, FormulaError::ParseError { .. }));
525 }
526
527 #[test]
528 fn from_formula_water_has_natural_isotopes_and_two_thirds_hydrogen() {
529 let water = Material::from_formula("H2O", &Ame2020, &NaturalAbundances, Some(1.0)).unwrap();
530 assert!(water
532 .comp
533 .contains_key(&NuclideId::from_name("H1").unwrap()));
534 for o in ["O16", "O17", "O18"] {
535 assert!(
536 water.comp.contains_key(&NuclideId::from_name(o).unwrap()),
537 "missing {o}"
538 );
539 }
540 let af = water.atom_fractions(&Ame2020).unwrap();
542 let h: f64 = af
543 .iter()
544 .filter(|(id, _)| id.z() == 1)
545 .map(|(_, f)| f)
546 .sum();
547 assert!((h - 2.0 / 3.0).abs() < 1e-12, "{h}");
548 let o: f64 = af
549 .iter()
550 .filter(|(id, _)| id.z() == 8)
551 .map(|(_, f)| f)
552 .sum();
553 assert!((o - 1.0 / 3.0).abs() < 1e-12);
554 assert_eq!(water.density(), Some(1.0));
556 }
557
558 #[test]
559 fn from_formula_h2so4() {
560 let mat = Material::from_formula("H2SO4", &Ame2020, &NaturalAbundances, None).unwrap();
561 let af = mat.atom_fractions(&Ame2020).unwrap();
562 assert!(!af.is_empty(), "H2SO4 atom fractions should not be empty");
563 for nuc in ["H1", "H2", "O16", "O17", "O18", "S32", "S33", "S34", "S36"] {
564 assert!(
565 af.contains_key(&NuclideId::from_name(nuc).unwrap()),
566 "missing {nuc}"
567 );
568 }
569 }
570
571 #[test]
572 fn from_formula_rejects_bad_input_and_missing_data() {
573 match Material::from_formula("Xx", &Ame2020, &NaturalAbundances, None).unwrap_err() {
574 FormulaError::UnknownElement(s) => assert_eq!(s, "Xx"),
575 other => panic!("{other:?}"),
576 }
577 assert!(matches!(
578 Material::from_formula("U", &Ame2020, &NoAbundances, None).unwrap_err(),
579 FormulaError::NoAbundanceData(92)
580 ));
581 }
582
583 #[test]
584 fn expand_then_collapse_round_trips_an_elemental_material() {
585 let mut mat = Material::new();
587 mat.add_nuclide(NuclideId::from_nucid(10_000_000), 2.0); mat.add_nuclide(NuclideId::from_nucid(80_000_000), 16.0); let original = mat.clone();
590
591 mat.expand_elements(&Ame2020, &NaturalAbundances).unwrap();
592 assert!(!mat.comp.contains_key(&NuclideId::from_nucid(80_000_000)));
594 assert!(mat.comp.contains_key(&NuclideId::from_name("H1").unwrap()));
595 assert!(mat.comp.contains_key(&NuclideId::from_name("H2").unwrap()));
596 assert!(mat.comp.contains_key(&NuclideId::from_name("O18").unwrap()));
597
598 let back = mat.collapse_elements();
599 assert_eq!(
600 back.comp.keys().copied().collect::<Vec<_>>(),
601 original.comp.keys().copied().collect::<Vec<_>>()
602 );
603 for (id, m0) in &original.comp {
604 let m1 = back.comp[id];
605 assert!((m0 - m1).abs() < 1e-9 * m0.abs(), "{id}: {m0} vs {m1}");
606 }
607 }
608
609 #[test]
610 fn expand_preserves_entry_masses_and_leaves_named_nuclides_alone() {
611 let mut mat = Material::new();
612 mat.add_nuclide(NuclideId::from_nucid(10_000_000), 18.0); mat.add_nuclide(NuclideId::from_name("Fe56").unwrap(), 5.0);
614
615 mat.expand_elements(&Ame2020, &NaturalAbundances).unwrap();
616 close(mat.mass(), 23.0);
617 close(
618 mat.remove_nuclide(NuclideId::from_name("Fe56").unwrap())
619 .unwrap(),
620 5.0,
621 );
622 let h: f64 = mat.comp.values().sum();
623 close(h, 18.0);
624 }
625
626 #[test]
627 fn expand_without_abundances_errors_with_z() {
628 let mut mat = Material::new();
629 mat.add_nuclide(NuclideId::from_nucid(920_000_000), 1.0);
630 match mat.expand_elements(&Ame2020, &NoAbundances).unwrap_err() {
631 FormulaError::NoAbundanceData(z) => assert_eq!(z, 92),
632 other => panic!("{other:?}"),
633 }
634 }
635
636 #[test]
637 fn collapse_folds_named_nuclides_into_placeholder_keys() {
638 let mut mat = Material::new();
639 mat.add_nuclide(NuclideId::from_name("U235").unwrap(), 3.0);
640 mat.add_nuclide(NuclideId::from_name("U238").unwrap(), 1.0);
641 mat.set_density(Some(19.1));
642 let collapsed = mat.collapse_elements();
643
644 let key = NuclideId::from_nucid(920_000_000);
645 assert_eq!(collapsed.comp.len(), 1);
646 close(collapsed.comp[&key], 4.0);
647 assert_eq!(
649 key.nucid(),
650 nucleide_nuclei::element_z("U").unwrap() * 10_000_000
651 );
652 assert_eq!(collapsed.density(), Some(19.1));
653 }
654
655 fn close(a: f64, b: f64) {
656 assert!((a - b).abs() < 1e-12, "{a} != {b}");
657 }
658}