Skip to main content

xtalforge_core/
elements.rs

1//! Periodic-table reference data and element-symbol parsing.
2//!
3//! The data is generated from Crystal-Builder `core/elements.py`,
4//! `core/pyykko.py` and `ff/vibrations.py` and embedded as a compressed JSON
5//! bundle (Task 1.1). Every lookup is cached after the first decompression.
6
7use serde::{Deserialize, Serialize};
8
9use crate::Error;
10use crate::data::{EmbeddedBundle, JsonBundle};
11
12const ELEMENTS_SHA256: &str = "7693a55f94d0f792a9b2e17338590e33e36d41c983f0b41740ce82855d92adf6";
13
14static ELEMENTS: JsonBundle<ElementsData> = JsonBundle::new(EmbeddedBundle::new(
15    "elements",
16    include_bytes!("../data/elements.json.gz"),
17    ELEMENTS_SHA256,
18));
19
20/// Reference data for one element or species.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct Element {
23    /// Atomic number; `0` for the dummy species `X`.
24    pub z: u8,
25    /// Element symbol, for example `Fe`, `D` or `X`.
26    pub symbol: String,
27    /// Full English name.
28    pub name: String,
29    /// Standard atomic mass in unified atomic mass units.
30    pub mass: f64,
31    /// Cordero 2008 covalent radius in Angstrom.
32    pub covalent_radius: f64,
33    /// van der Waals radius in Angstrom (Bondi where defined).
34    pub vdw_radius: f64,
35    /// Default Jmol/CPK colour as 0-255 RGB.
36    pub color: [u8; 3],
37    /// Whether the element is a metal in the reference table.
38    pub is_metal: bool,
39    /// Typical valence used by the UFF atom typer, when defined.
40    pub valence: Option<u8>,
41    /// Pauling electronegativity, when defined.
42    pub electronegativity: Option<f64>,
43    /// Pyykko single-bond covalent radius in Angstrom.
44    pub pyykko_single: Option<f64>,
45    /// Pyykko double-bond covalent radius in Angstrom.
46    pub pyykko_double: Option<f64>,
47    /// Pyykko aromatic radius in Angstrom (single radius when no double one).
48    pub pyykko_aromatic: Option<f64>,
49}
50
51impl Element {
52    /// Whether this is the dummy connection-point species `X`.
53    #[must_use]
54    pub fn is_dummy(&self) -> bool {
55        self.symbol == "X"
56    }
57
58    /// Whether this is an isotope species such as deuterium.
59    #[must_use]
60    pub fn is_isotope(&self) -> bool {
61        self.symbol == "D"
62    }
63}
64
65/// An isotope that the application spells with its own symbol.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct Isotope {
68    /// Isotope symbol, for example `D`.
69    pub symbol: String,
70    /// Element whose energy surface the isotope shares.
71    pub element: String,
72    /// Isotope mass in unified atomic mass units.
73    pub mass: f64,
74}
75
76#[derive(Debug, Deserialize)]
77struct ElementsData {
78    schema_version: u32,
79    source: String,
80    elements: Vec<Element>,
81    isotopes: Vec<Isotope>,
82}
83
84fn data() -> Result<&'static ElementsData, Error> {
85    ELEMENTS.get()
86}
87
88/// Schema version of the embedded element bundle.
89///
90/// # Errors
91///
92/// Returns a data-bundle error if the embedded table is corrupt.
93pub fn schema_version() -> Result<u32, Error> {
94    Ok(data()?.schema_version)
95}
96
97/// Provenance string of the embedded element bundle.
98///
99/// # Errors
100///
101/// Returns a data-bundle error if the embedded table is corrupt.
102pub fn data_source() -> Result<&'static str, Error> {
103    Ok(data()?.source.as_str())
104}
105
106/// Every element and species in atomic-number order, followed by deuterium
107/// and the dummy species `X`.
108///
109/// # Errors
110///
111/// Returns a data-bundle error if the embedded table is corrupt.
112pub fn all() -> Result<&'static [Element], Error> {
113    Ok(&data()?.elements)
114}
115
116/// Every isotope that has its own symbol.
117///
118/// # Errors
119///
120/// Returns a data-bundle error if the embedded table is corrupt.
121pub fn isotopes() -> Result<&'static [Isotope], Error> {
122    Ok(&data()?.isotopes)
123}
124
125/// Element by atomic number, `1..=118`; `0` returns the dummy `X`.
126///
127/// Deuterium is a separate species; `element_by_z(1)` returns hydrogen.
128///
129/// # Errors
130///
131/// Returns [`Error::InvalidInput`] for a number outside `0..=118`.
132pub fn element_by_z(z: u8) -> Result<&'static Element, Error> {
133    if z > 118 {
134        return Err(Error::InvalidInput(format!(
135            "atomic number out of range: {z}"
136        )));
137    }
138    if z == 0 {
139        return element_by_symbol("X");
140    }
141    all()?
142        .iter()
143        .find(|element| element.z == z && element.symbol != "D")
144        .ok_or_else(|| Error::InvalidInput(format!("no element with atomic number {z}")))
145}
146
147/// Element by messy symbol (`Fe`, `FE`, `Fe2+`, `O_1`, `C1A`, `Ow`, `D`, `X`).
148///
149/// # Errors
150///
151/// Returns [`Error::InvalidInput`] when the text has no known element symbol.
152pub fn element_by_symbol(symbol: &str) -> Result<&'static Element, Error> {
153    let canonical = parse_symbol(symbol)?;
154    all()?
155        .iter()
156        .find(|element| element.symbol == canonical)
157        .ok_or_else(|| Error::InvalidInput(format!("unknown element symbol: {symbol:?}")))
158}
159
160/// Canonical symbol for a `1..=118` atomic number.
161///
162/// # Errors
163///
164/// Returns [`Error::InvalidInput`] for a number outside `1..=118`.
165pub fn symbol_from_z(z: u8) -> Result<String, Error> {
166    if !(1..=118).contains(&z) {
167        return Err(Error::InvalidInput(format!(
168            "atomic number out of range: {z}"
169        )));
170    }
171    Ok(element_by_z(z)?.symbol.clone())
172}
173
174/// Atomic number of a messy symbol.
175///
176/// # Errors
177///
178/// Returns [`Error::InvalidInput`] when the symbol is unknown.
179pub fn atomic_number(symbol: &str) -> Result<u8, Error> {
180    Ok(element_by_symbol(symbol)?.z)
181}
182
183/// Normalise file-shaped text into an element symbol.
184///
185/// The first one or two ASCII letters are tried, two letters first:
186/// `Fe`, `FE`, `Fe2+`, `Fe1`, `O_1`, `C1A` and `Ow` all resolve. `Co` is
187/// cobalt, while `C1` is carbon; `Kryptonite` parses as krypton because the
188/// rule is prefix-based, which is why [`canonical_symbol`] exists for user
189/// input.
190///
191/// # Errors
192///
193/// Returns [`Error::InvalidInput`] when no known symbol is found.
194pub fn parse_symbol(text: &str) -> Result<String, Error> {
195    let trimmed = text.trim();
196    if trimmed.is_empty() {
197        return Err(Error::InvalidInput("empty element symbol".to_owned()));
198    }
199    let letters: String = trimmed
200        .chars()
201        .take_while(|character| character.is_ascii_alphabetic())
202        .take(2)
203        .collect();
204    if letters.is_empty() {
205        return Err(Error::InvalidInput(format!(
206            "no element symbol in {text:?}"
207        )));
208    }
209    if letters.len() == 2 {
210        let candidate = format!(
211            "{}{}",
212            letters.chars().next().unwrap_or('?').to_ascii_uppercase(),
213            letters.chars().nth(1).unwrap_or('?').to_ascii_lowercase()
214        );
215        if find_exact(&candidate)?.is_some() {
216            return Ok(candidate);
217        }
218    }
219    let one = letters
220        .chars()
221        .next()
222        .unwrap_or('?')
223        .to_ascii_uppercase()
224        .to_string();
225    if find_exact(&one)?.is_some() {
226        return Ok(one);
227    }
228    Err(Error::InvalidInput(format!(
229        "unknown element symbol: {text:?}"
230    )))
231}
232
233/// Strict symbol reading: the whole trimmed string must be one or two letters,
234/// case aside. `fe` and `FE` give `Fe`; `Fe2+`, `C1` and `Kryptonite` give
235/// `None`.
236///
237/// # Errors
238///
239/// Returns a data-bundle error if the embedded table is corrupt.
240pub fn canonical_symbol(text: &str) -> Result<Option<String>, Error> {
241    let trimmed = text.trim();
242    if trimmed.is_empty() || trimmed.len() > 2 || !trimmed.chars().all(|c| c.is_ascii_alphabetic())
243    {
244        return Ok(None);
245    }
246    let mut characters = trimmed.chars();
247    let first = characters.next().unwrap_or('?').to_ascii_uppercase();
248    let candidate = match characters.next() {
249        Some(second) => format!("{first}{}", second.to_ascii_lowercase()),
250        None => first.to_string(),
251    };
252    Ok(find_exact(&candidate)?.map(|_| candidate))
253}
254
255/// Whether [`canonical_symbol`] accepts the text.
256///
257/// # Errors
258///
259/// Returns a data-bundle error if the embedded table is corrupt.
260pub fn is_symbol(text: &str) -> Result<bool, Error> {
261    Ok(canonical_symbol(text)?.is_some())
262}
263
264/// Isotope record for a symbol, when it has one.
265///
266/// # Errors
267///
268/// Returns a data-bundle error if the embedded table is corrupt.
269pub fn isotope(symbol: &str) -> Result<Option<&'static Isotope>, Error> {
270    Ok(isotopes()?.iter().find(|isotope| isotope.symbol == symbol))
271}
272
273/// Mass of an isotope or element symbol in unified atomic mass units.
274///
275/// # Errors
276///
277/// Returns a data-bundle or invalid-symbol error.
278pub fn mass_of(symbol: &str) -> Result<f64, Error> {
279    if let Some(isotope) = isotope(parse_symbol(symbol)?.as_str())? {
280        return Ok(isotope.mass);
281    }
282    Ok(element_by_symbol(symbol)?.mass)
283}
284
285/// Element information by atomic number (`"26"`) or messy symbol (`"Fe"`).
286///
287/// # Errors
288///
289/// Returns [`Error::InvalidInput`] when the input is neither a valid number
290/// nor a known symbol, or a data-bundle error if the table is corrupt.
291pub fn element_info(input: &str) -> Result<Element, Error> {
292    let trimmed = input.trim();
293    if let Ok(z) = trimmed.parse::<u8>() {
294        return Ok(element_by_z(z)?.clone());
295    }
296    Ok(element_by_symbol(trimmed)?.clone())
297}
298
299fn find_exact(symbol: &str) -> Result<Option<&'static Element>, Error> {
300    Ok(all()?.iter().find(|element| element.symbol == symbol))
301}