Skip to main content

omgkit_core/
element.rs

1//! 元素周期表。
2//!
3//! 数据由 `harness/gen_elements.py` 生成到
4//! `element_data`。**默认价表直接决定 L2 的隐式氢推断**,任何一处
5//! 数字不一致都会在 ChEMBL 差分测试里表现为成千条难以定位的分歧,
6//! 所以它是生成的而非手写的。
7
8use crate::element_data::{symbol_to_atomic_num, ELEMENTS, ISOTOPE_MASSES, IS_EARLY_ATOM};
9
10/// 单个元素的静态数据。
11#[derive(Debug, Clone, Copy)]
12pub struct Element {
13    /// 原子序数。0 为 SMILES 通配原子 `*`。
14    pub atomic_num: u8,
15    /// 元素符号(首字母大写形式)
16    pub symbol: &'static str,
17    /// 周期
18    pub period: u8,
19    /// 共价半径 (Å)。
20    ///
21    /// **`1.9` 是上游的"未知"哨兵,不是一个测量值** —— `atomic_data.cpp:34`
22    /// 原话是 `rCov (…). 1.9 if unknown.`。转录时那句声明丢了,于是表里 19 处
23    /// `1.9` 里,Z=97..112 那连续 16 个是"没有数据",而 Y / Tm / Np 恰好真的
24    /// 就是 1.90 —— 下游分不出这两件事。
25    ///
26    /// 同一个 struct 里 [`electronegativity`](Self::electronegativity) 花五行
27    /// 文档反对的正是这种做法(用一个魔数当"不知道")。这里没有改成 `Option`,
28    /// 是因为下游(`omgkit-conf` 的界矩阵)拿它当兜底模型用,给 `None` 就得
29    /// 在那里再编一个数 —— 换汤不换药。**要改就得连同"没有共价半径的元素
30    /// 该怎么建界"一起改**,那是另一件事。
31    pub rcov: f32,
32    /// 范德华半径 (Å)
33    pub rvdw: f32,
34    /// 标准原子量。
35    ///
36    /// **f64 而不是 f32**:上游表里写的是 `12.011`,存成 f32 再读回来是
37    /// 12.0109996…,差 3e-7。这点差在化学上无关紧要,可它让"与参照逐位相同"
38    /// 变成"与参照差在末几位",判据只好配一个容差 —— 容差一旦有了,就再也
39    /// 分不出"存储精度"和"抄错了一位"。电负性同理。
40    pub mass: f64,
41    /// 外层电子数
42    pub outer_electrons: u8,
43    /// 最常见同位素的质量数
44    pub common_isotope: u16,
45    /// 最常见同位素的精确质量
46    pub common_isotope_mass: f64,
47    /// Pauling 电负性。
48    ///
49    /// `None` 表示**该元素没有公认的 Pauling 值**(稀有气体 He/Ne/Ar/Rn、
50    /// Pm、Eu、Tb、Yb、Fr 等),不是"还没填"。两者必须能被下游分开:
51    /// 拿一个默认数顶上去,调用方就再也看不出这一格是量出来的还是补的。
52    pub electronegativity: Option<f64>,
53    /// 默认价列表。`-1` 表示无价约束(该元素不参与隐式氢推断)。
54    pub valences: &'static [i8],
55}
56
57impl Element {
58    /// 该元素是否有价约束。无约束的元素(多数金属)不推断隐式氢。
59    #[must_use]
60    pub fn has_valence_constraint(&self) -> bool {
61        !self.valences.is_empty() && self.valences[0] != -1
62    }
63
64    /// 给定已用价数,返回应当采用的默认价。
65    ///
66    /// 规则:取第一个 ≥ 已用价的默认价;若全部小于已用价,
67    /// 说明是超价(hypervalent),返回 `None`,由调用方决定是报错还是放行。
68    #[must_use]
69    pub fn default_valence_for(&self, used: i8) -> Option<i8> {
70        if !self.has_valence_constraint() {
71            return None;
72        }
73        self.valences.iter().copied().find(|&v| v >= used)
74    }
75}
76
77/// 按原子序数取元素。越界返回 `None`。
78#[must_use]
79pub fn by_atomic_num(atomic_num: u8) -> Option<&'static Element> {
80    ELEMENTS.get(atomic_num as usize)
81}
82
83/// 按元素符号取元素(大小写敏感,如 `"Cl"`)。
84#[must_use]
85pub fn by_symbol(symbol: &str) -> Option<&'static Element> {
86    symbol_to_atomic_num(symbol).and_then(by_atomic_num)
87}
88
89/// 元素符号 → 原子序数。
90#[must_use]
91pub fn atomic_num_of(symbol: &str) -> Option<u8> {
92    symbol_to_atomic_num(symbol)
93}
94
95/// 某个同位素的精确质量。该元素/质量数不在表里时返回 `None`。
96///
97/// 与"标准原子量"([`Element::mass`])是两件事:后者是天然丰度加权的平均值,
98/// 与写不写同位素标注无关;这里给的是**指定那一个核素**的质量。
99/// 氘(`[2H]`)的标准原子量仍是 1.008,精确质量是 2.0141 —— 差了一倍,
100/// 混用会在氘代化合物上悄悄给出错的数。
101///
102/// 表由 `harness/gen_elements.py` 从 RDKit `atomic_data.cpp` 的
103/// `isotopesAtomData` 抽取(3111 条,覆盖 113 种元素;105–109 号在源表里
104/// 本来就没有数据)。
105#[must_use]
106pub fn isotope_mass(atomic_num: u8, mass_number: u16) -> Option<f64> {
107    ISOTOPE_MASSES
108        .get(atomic_num as usize)?
109        .iter()
110        .find(|&&(m, _)| m == mass_number)
111        .map(|&(_, mass)| mass)
112}
113
114/// 表中元素总数(含 0 号通配原子)。
115#[must_use]
116pub fn count() -> usize {
117    ELEMENTS.len()
118}
119
120/// 元素是否位于周期表中碳的左侧。
121///
122/// 用于 kekulize 的 `markDbondCands`:早期元素的形式电荷参与默认价计算时
123/// 要取反。表由 `harness/gen_elements.py` 从
124/// `Code/GraphMol/Atom.cpp` 抽取。
125#[must_use]
126pub fn is_early_atom(atomic_num: u8) -> bool {
127    IS_EARLY_ATOM
128        .get(atomic_num as usize)
129        .copied()
130        .unwrap_or(false)
131}
132
133/// SMILES **有机子集** —— 这些元素可以不加方括号直接书写,
134/// 其隐式氢由默认价推断。其余元素必须写在 `[...]` 中。
135///
136/// 见 OpenSMILES 规范 §3.1.5。注意芳香形式 `b c n o p s` 也属于此集合。
137#[must_use]
138pub fn is_organic_subset(atomic_num: u8) -> bool {
139    matches!(
140        atomic_num,
141        5 | 6 | 7 | 8 | 15 | 16 | 9 | 17 | 35 | 53 // B C N O P S F Cl Br I
142    )
143}
144
145/// 可以以芳香小写形式出现在 SMILES 中的元素。
146///
147/// 这是**语法**层面的白名单(解析器用),不是芳香性感知的结果 ——
148/// 后者属于 L2。
149#[must_use]
150pub fn can_be_aromatic_lowercase(atomic_num: u8) -> bool {
151    matches!(atomic_num, 5 | 6 | 7 | 8 | 15 | 16 | 33 | 34 | 52) // b c n o p s as se te
152}
153
154/// 这个原子在**三配位**状态下,第四个"取代基"是一对孤对电子 ——
155/// 于是它照样可以是四面体立体中心。
156///
157/// 亚砜、亚磺酰胺、亚砜亚胺的 S,膦、膦氧化物的 P,以及同族的 As / Se / Te。
158///
159/// # 六族的 +1 也算:锍盐、硒盐、碲盐
160///
161/// R₃S⁺ 是三配体 + 一对孤对(S⁺ 有 5 个价电子,三根键用掉 3 个,剩 2 个正好一对),
162/// 构型稳定、可以拆分。真正没有孤对、四配体全在的是季铵 R₄N⁺。
163///
164/// 这一档先前被整个排除,理由写的是"外部判据看不见":当时举的例子是
165/// `C[S@+](C)CC` —— 而那个分子**两个甲基一模一样,本来就不是手性中心**,
166/// 任何实现都会把标记清掉。换成真正的锍盐 `C[S@+](CC)CCC`,钉住的 RDKit 2025.09.2
167/// 给的是 `CHI_TETRAHEDRAL_CCW`。**拿一个非手性的例子论证"判据看不见",
168/// 论证的是别的事。**
169///
170/// 代价是实打实的:排除期间 `C[S@+](CC)CCC` 走一趟二维往返构型整个丢掉
171/// (`C[S@@+](CCC)CC` → `C[S+](CCC)CC`),不报错。
172///
173/// **五族(P / As)的 +1 仍然不算**,而且不是保守取舍:P⁺ 只有 4 个价电子,
174/// 三根键用掉 3 个,剩下的是**一个单电子**而不是一对 —— 那是膦自由基阳离子,
175/// 不是稳定的立体中心。四配位的鏻盐 R₄P⁺ 本来就走四邻居那条路。
176///
177/// # 为什么这条要放在 core
178///
179/// 它不是算法,是一张化学事实表,而**两个 crate 都要问同一个问题**:
180/// `omgkit-depict` 从楔形反读构型时要知道"三根键也能定构型",
181/// `omgkit-conf` 抽手性中心时要知道"三个邻居也算数"。
182/// 各写一份的话迟早分岔,而分岔的表现是一半的中心画对了、另一半摆错了。
183///
184/// (实测:`omgkit-conf` 先前根本不认这一档 —— `<[u32; 4]>::try_from` 凑不够
185/// 四个邻居就整个 `continue`,于是语料里 13 个分子、16 个中心的构型是掷硬币。)
186#[must_use]
187pub fn has_stereogenic_lone_pair(atomic_num: u8, formal_charge: i8) -> bool {
188    match atomic_num {
189        // 六族:中性(亚砜等)与 +1(锍盐、硒盐、碲盐)都是三配体 + 一对孤对
190        16 | 34 | 52 => formal_charge <= 1,
191        // 五族:中性的膦、胂有孤对;+1 之后剩的是单电子,不是一对
192        15 | 33 => formal_charge <= 0,
193        _ => false,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn 孤对立体中心认哪几个元素与电荷() {
203        for (z, chg, want) in [
204            (16u8, 0i8, true), // S:亚砜
205            (15, 0, true),     // P:膦
206            (33, 0, true),     // As
207            (34, 0, true),     // Se
208            (52, 0, true),     // Te
209            // 六族的 +1 也算:锍盐 R₃S⁺ 是三配体 + 一对孤对,钉住的外部实现
210            // 对**真正**的锍盐(三个取代基不全同)给的是四面体标记。
211            (16, 1, true), // [S+]:锍盐
212            (34, 1, true), // [Se+]:硒盐
213            (52, 1, true), // [Te+]:碲盐
214            // 五族的 +1 不算,而且不是保守取舍:P⁺ 三配位剩的是单电子不是一对。
215            (15, 1, false), // [P+]
216            (33, 1, false), // [As+]
217            // 再往上就是四配位了,不走这一支
218            (16, 2, false),
219            (7, 0, false), // N:孤对翻转太快,不当立体中心
220            (6, 0, false), // C:三配位是 sp²
221            (8, 0, false), // O:三配位是 [O+]
222        ] {
223            assert_eq!(
224                has_stereogenic_lone_pair(z, chg),
225                want,
226                "元素 {z} 电荷 {chg}"
227            );
228        }
229    }
230
231    #[test]
232    fn table_is_indexed_by_atomic_number() {
233        for (i, e) in ELEMENTS.iter().enumerate() {
234            assert_eq!(e.atomic_num as usize, i, "第 {i} 项的原子序数错位");
235        }
236    }
237
238    #[test]
239    fn covers_through_oganesson() {
240        // 118 号 + 0 号通配原子
241        assert_eq!(count(), 119, "元素表应覆盖 0..=118");
242        assert_eq!(by_atomic_num(118).unwrap().symbol, "Og");
243        assert_eq!(by_atomic_num(0).unwrap().symbol, "*");
244    }
245
246    #[test]
247    fn symbol_roundtrip() {
248        for e in ELEMENTS.iter() {
249            assert_eq!(
250                atomic_num_of(e.symbol),
251                Some(e.atomic_num),
252                "符号 {} 无法反查",
253                e.symbol
254            );
255        }
256    }
257
258    /// 电负性表的护栏。
259    ///
260    /// 表是从 RDKit 源码抽的,而 CI 里没有那份源码 —— 重新生成一遍再逐字节比
261    /// 的闸进不了 CI,进不了 CI 就不是闸。所以这里钉两样在 CI 里跑得动的:
262    ///
263    /// 1. **几个课本上的值**(氟最大、铯最小、碳氧氢),抓整表错位与量纲写错;
264    /// 2. **哪些元素没有值**,以及有值的元素**个数**。
265    ///
266    /// 第 2 条是关键:没有公认 Pauling 值的元素(稀有气体、Pm/Eu/Tb/Yb/Fr)
267    /// 必须是 `None`。谁要是给它们补个"默认 2.0",这里立刻红 —— 那种补法会让
268    /// 下游再也分不出"这个元素没有值"和"这个元素的值恰好是 2.0"。
269    #[test]
270    fn pauling_electronegativity_table() {
271        let en = |sym: &str| by_symbol(sym).unwrap().electronegativity;
272        // 课本值:氟最大 3.98,铯最小 0.79
273        assert_eq!(en("F"), Some(3.98));
274        assert_eq!(en("Cs"), Some(0.79));
275        assert_eq!(en("H"), Some(2.2));
276        assert_eq!(en("C"), Some(2.55));
277        assert_eq!(en("N"), Some(3.04));
278        assert_eq!(en("O"), Some(3.44));
279        // 全表的最大值就该是氟
280        let max = ELEMENTS
281            .iter()
282            .filter_map(|e| e.electronegativity)
283            .fold(f64::NEG_INFINITY, f64::max);
284        assert!(
285            (max - 3.98).abs() < f64::EPSILON,
286            "全表最大电负性应为氟的 3.98,实得 {max}"
287        );
288
289        // 没有公认值的:如实为 None
290        for sym in ["He", "Ne", "Ar", "Rn", "Pm", "Eu", "Tb", "Yb", "Fr"] {
291            assert_eq!(en(sym), None, "{sym} 没有公认的 Pauling 值,应为 None");
292        }
293        // 通配原子当然也没有
294        assert_eq!(by_atomic_num(0).unwrap().electronegativity, None);
295
296        let n = ELEMENTS
297            .iter()
298            .filter(|e| e.electronegativity.is_some())
299            .count();
300        assert_eq!(
301            n, 93,
302            "有电负性的元素个数变了 —— 表被改过,重跑 gen_elements.py"
303        );
304    }
305
306    /// 同位素质量表的护栏。CI 里没有 RDKit 源码,所以钉几个核素 + 一条
307    /// "标准原子量与精确质量不是一回事"的对照。
308    #[test]
309    fn isotope_mass_table() {
310        // 氕与氘:标准原子量都是 1.008,精确质量差了一倍
311        assert_eq!(by_symbol("H").unwrap().mass, 1.008);
312        assert_eq!(isotope_mass(1, 1), Some(1.007_825_032));
313        assert_eq!(isotope_mass(1, 2), Some(2.014_101_778));
314        assert_eq!(isotope_mass(6, 12), Some(12.0));
315        assert_eq!(isotope_mass(6, 13), Some(13.00335484));
316        assert_eq!(isotope_mass(7, 15), Some(15.0001089));
317        // 表里没有的质量数、以及源表本来就没有数据的元素(105–109)
318        assert_eq!(isotope_mass(6, 200), None);
319        assert_eq!(isotope_mass(105, 268), None);
320        // 每个有数据的元素都得含它最常见的那个同位素 —— 分块解析漏首行时,
321        // 漏掉的恰恰是排在最前的质量数(实测漏过 H-1)
322        for e in ELEMENTS.iter() {
323            let rows = ISOTOPE_MASSES[e.atomic_num as usize];
324            if rows.is_empty() || e.common_isotope == 0 {
325                continue;
326            }
327            assert!(
328                isotope_mass(e.atomic_num, e.common_isotope).is_some(),
329                "{} 的最常见同位素 {} 不在表里",
330                e.symbol,
331                e.common_isotope
332            );
333        }
334        let n: usize = ISOTOPE_MASSES.iter().map(|r| r.len()).sum();
335        assert_eq!(n, 3111, "同位素条目数变了 —— 表被改过,重跑 gen_elements.py");
336    }
337
338    /// 这些默认价是隐式氢推断的基石。
339    /// 此测试是防止生成器回归的护栏。
340    #[test]
341    fn organic_subset_default_valences() {
342        assert_eq!(by_symbol("C").unwrap().valences, &[4]);
343        assert_eq!(by_symbol("N").unwrap().valences, &[3]);
344        assert_eq!(by_symbol("O").unwrap().valences, &[2]);
345        assert_eq!(by_symbol("F").unwrap().valences, &[1]);
346        assert_eq!(by_symbol("B").unwrap().valences, &[3]);
347        assert_eq!(by_symbol("H").unwrap().valences, &[1]);
348    }
349
350    /// 多价元素的默认价表:**期望值写死,不许引用被测的那张表**。
351    ///
352    /// 先前这里写的是 `assert_eq!(s.default_valence_for(3), Some(s.valences[1]))`
353    /// —— 表一改两边一起动。把 S 的 `[2,4,6]` 变异成 `[2,5,6]` 它照样绿,而那个
354    /// 变异会让 `[SH](=O)C` 的隐式氢从 1 变 2。
355    ///
356    /// 而且先前只钉了六个**单值**列表,P `[3,5]` / S `[2,4,6]` / I `[1,3,5]`
357    /// 一个没钉 —— 多值那档才是抄错后果最重的。
358    #[test]
359    fn default_valence_selection() {
360        for (sym, want) in [
361            ("S", &[2, 4, 6][..]),
362            ("P", &[3, 5][..]),
363            ("I", &[1, 3, 5][..]),
364            ("Cl", &[1][..]),
365            ("N", &[3][..]),
366            ("C", &[4][..]),
367        ] {
368            let e = by_symbol(sym).unwrap();
369            assert_eq!(e.valences, want, "{sym} 的默认价表");
370        }
371
372        let s = by_symbol("S").unwrap();
373        // 选的是第一个 ≥ 已用价的那一档
374        assert_eq!(s.default_valence_for(2), Some(2));
375        assert_eq!(s.default_valence_for(3), Some(4));
376        assert_eq!(s.default_valence_for(5), Some(6));
377        assert_eq!(s.default_valence_for(7), None, "超过最大价就没有可用的了");
378
379        let c = by_symbol("C").unwrap();
380        assert_eq!(c.default_valence_for(4), Some(4));
381        assert_eq!(c.default_valence_for(5), None);
382    }
383
384    #[test]
385    fn unknown_symbol_is_none() {
386        assert!(by_symbol("Xx").is_none());
387        assert!(
388            by_symbol("c").is_none(),
389            "小写芳香符号应由调用方归一化后再查表"
390        );
391    }
392
393    #[test]
394    fn organic_subset_membership() {
395        for sym in ["B", "C", "N", "O", "P", "S", "F", "Cl", "Br", "I"] {
396            let a = atomic_num_of(sym).unwrap();
397            assert!(is_organic_subset(a), "{sym} 应属于有机子集");
398        }
399        for sym in ["Si", "Se", "Na", "Fe"] {
400            let a = atomic_num_of(sym).unwrap();
401            assert!(!is_organic_subset(a), "{sym} 不应属于有机子集");
402        }
403    }
404}