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}