Skip to main content

omgkit_core/
valence.rs

1//! 价键与**隐式氢推断的规则本身**。
2//!
3//! # 为什么这条规则住在 core
4//!
5//! 用它的有两处,分处两个 crate:
6//!
7//! - `omgkit-chem` 的净化第 3 步 —— 把隐式氢算出来写回分子。
8//! - `omgkit-io` 的 SMILES 写出 —— 决定一个原子能不能去掉方括号。去框之后
9//!   氢数由**读者**按同一条规则反推,所以写出侧必须先算出"读者会补几个氢"。
10//!
11//! 两处各写一遍必然分岔,而且是**静默**分岔:写出侧算多了就少去几个框
12//! (只是啰嗦),算少了就写出**另一个分子**。先前正是各写一遍,写出侧那份
13//! 里还留着一条注释写明"一处已知的不同步:芳香价回落这边没有"。
14//!
15//! 所以规则只留一份,放在两个 crate 都够得着的地方。
16//! 净化那一步(把结果写回分子)留在 `omgkit-chem`。
17//!
18//! # 三个容易写错的地方
19//!
20//! **1. `dv` 与 `valens` 取自不同的有效原子序数。**
21//! `dv`(默认价)用**调整前**的有效原子序数取,超价元素调整之后再用**调整后**
22//! 的取 `valens`(允许的价态表)。两步顺序颠倒,结果就不同。
23//!
24//! **2. 无价约束的元素不进芳香分支。**
25//! 这类元素的默认价是 `-1`,判据里的 `dv >= 0 &&` 就是把它们挡在外面。
26//! 少了这个条件,过渡金属之类会误入只对有机元素成立的芳香价态修正。
27//!
28//! **3. 配位键的价贡献不对称。**
29//! 对起点(给体)算 0,对终点(受体)算 1。见
30//! [`BondData::valence_contribution_to`](crate::BondData::valence_contribution_to)。
31//!
32//! # 自由基电子数
33//!
34//! 隐式氢的推断要读自由基电子数,而它由净化第 6 步填充 —— 排在价键那步
35//! **之后**。所以净化管线中那一步看到的必然是 0。
36//!
37//! 即便如此,这里也**读字段而不是写死 0**:这是公开 API,在净化完成之后
38//! 再调一次是正常用法,那时拿到的才应是正确结果。写死 0 能在管线内蒙对,
39//! 却会在管线外静默给出错误的氢数。
40
41use crate::{element, AtomFlags, BondFlags, BondOrder, MolBuilder};
42
43/// 价键校验失败。
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ValenceError {
46    /// 出问题的原子下标
47    pub atom: u32,
48    /// 元素符号
49    pub symbol: &'static str,
50    /// 算出的显式价
51    pub valence: i32,
52    /// 具体原因
53    pub kind: ValenceErrorKind,
54}
55
56/// 价键校验失败的类别。
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum ValenceErrorKind {
60    /// 显式价超出该元素允许的最大值
61    ExplicitValenceTooHigh,
62    /// 芳香原子的显式价不等于任何允许的价态
63    AromaticValenceNotAllowed,
64    /// 形式电荷不合理(仅 H 的特殊分支)
65    UnreasonableFormalCharge,
66}
67
68impl core::fmt::Display for ValenceError {
69    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
70        let what = match self.kind {
71            ValenceErrorKind::ExplicitValenceTooHigh => "显式价超出允许范围",
72            ValenceErrorKind::AromaticValenceNotAllowed => "芳香原子的显式价不在允许的价态中",
73            ValenceErrorKind::UnreasonableFormalCharge => "形式电荷不合理",
74        };
75        write!(
76            f,
77            "原子 #{}({}):{},显式价 = {}",
78            self.atom, self.symbol, what, self.valence
79        )
80    }
81}
82
83impl std::error::Error for ValenceError {}
84
85/// 原子自身带芳香标志,**或**任一关联键是芳香键。
86#[must_use]
87pub fn is_aromatic_atom(mol: &MolBuilder, idx: u32) -> bool {
88    if mol.atoms()[idx as usize]
89        .flags
90        .contains(AtomFlags::AROMATIC)
91    {
92        return true;
93    }
94    mol.neighbors(idx).any(|(_, bi)| {
95        let b = mol.bonds()[bi as usize];
96        b.flags.contains(BondFlags::AROMATIC) || b.order == BondOrder::Aromatic
97    })
98}
99
100/// 形式电荷从 `from` 变到 `to` 时,这个元素**能用的价**变了多少。
101///
102/// # 为什么不能写成"负电荷减一、正电荷加一"
103///
104/// 价由**有效原子序数**定(带电原子按 Z∓q 那个元素的价表算),所以电荷对价的
105/// 作用完全取决于元素:
106///
107/// | | 中性 | 带电 | 变化 |
108/// |---|---|---|---|
109/// | N⁺ | 3 | 4 | **+1** |
110/// | O⁻ | 2 | 1 | −1 |
111/// | C⁻ | 4 | 3 | −1 |
112/// | **C⁺** | 4 | 3 | **−1**(与 N⁺ 反向) |
113/// | **B⁻** | 3 | 4 | **+1**(与 O⁻ 反向) |
114///
115/// 按"负减正加"写死的话,碳正离子与硼酸根这两类会算反。返回 0 表示这个元素
116/// 没有价约束(或电荷离谱到查不出价),调用方应当把它当作"说不准"。
117#[must_use]
118pub fn valence_shift(z: u8, from: i8, to: i8) -> i32 {
119    let ovalens = valences_of(z);
120    if ovalens.len() == 1 && ovalens[0] == -1 {
121        return 0; // 无价约束的元素
122    }
123    let at = |q: i8| -> Option<i32> {
124        let eff = effective_atomic_num(z, q);
125        if eff == 0 {
126            return None;
127        }
128        let v = default_valence(eff);
129        if v < 0 {
130            None
131        } else {
132            Some(v)
133        }
134    };
135    match (at(from), at(to)) {
136        (Some(a), Some(b)) => b - a,
137        _ => 0,
138    }
139}
140
141/// 有效原子序数:`clamp(Z − 形式电荷, 0, 最大原子序数)`。
142fn effective_atomic_num(z: u8, charge: i8) -> u8 {
143    let max = (element::count() - 1) as i32;
144    (i32::from(z) - i32::from(charge)).clamp(0, max) as u8
145}
146
147/// 带负电的 P/S/As/Se 可以保留超价形式,尽管它们与不支持超价的
148/// Cl/Ar、Br/Kr 等电子。
149fn can_be_hypervalent(z: u8, eff_z: u8) -> bool {
150    (eff_z > 16 && (z == 15 || z == 16)) || (eff_z > 34 && (z == 33 || z == 34))
151}
152
153fn valences_of(z: u8) -> &'static [i8] {
154    element::by_atomic_num(z).map_or(&[-1][..], |e| e.valences)
155}
156
157/// 价列表的首项,即默认价。
158fn default_valence(z: u8) -> i32 {
159    valences_of(z).first().map_or(-1, |&v| i32::from(v))
160}
161
162/// 价列表的末项,即最大允许价。
163fn last_valence(z: u8) -> i32 {
164    valences_of(z).last().map_or(-1, |&v| i32::from(v))
165}
166
167/// 非严格模式的显式价:跳过超价校验,永不失败。
168///
169/// 净化第 1 步全程用它 —— 那一步跑在价键校验之前,分子里本就存在
170/// `N(=O)=O` 这类超价写法,而它的职责恰恰是把这些写法修成合法形式。
171#[must_use]
172pub fn explicit_valence_nonstrict(mol: &MolBuilder, idx: u32) -> i32 {
173    explicit_valence_of(mol, idx, false).unwrap_or(0)
174}
175
176/// 计算显式价。`strict` 为假时跳过超价校验,永不返回 `Err`。
177///
178/// # Errors
179/// `strict` 为真且该原子超价时返回 [`ValenceError`]。
180pub fn explicit_valence_of(mol: &MolBuilder, idx: u32, strict: bool) -> Result<i32, ValenceError> {
181    let hs = f32::from(mol.atoms()[idx as usize].num_explicit_hs);
182    explicit_valence_with(mol, idx, hs, strict)
183}
184
185/// [`explicit_valence_of`] 的内核,把"方括号里写死了几个氢"作为参数。
186///
187/// 裸写形式(去掉方括号)那一档要传 0 —— 见 [`implicit_hs_for_bare_form`]。
188/// 抽出来是为了让两处走**同一份**代码,而不是各写一遍。
189fn explicit_valence_with(
190    mol: &MolBuilder,
191    idx: u32,
192    extra_hs: f32,
193    strict: bool,
194) -> Result<i32, ValenceError> {
195    let atom = mol.atoms()[idx as usize];
196    let z = atom.atomic_num;
197
198    let mut accum: f32 = mol
199        .neighbors(idx)
200        .map(|(_, bi)| mol.bonds()[bi as usize].valence_contribution_to(idx))
201        .sum();
202    accum += extra_hs;
203
204    let ovalens = valences_of(z);
205    // 只有当该元素本身有价约束时才启用"有效原子序数"(即扣除形式电荷)
206    let eff_z = if ovalens.len() > 1 || ovalens[0] != -1 {
207        effective_atomic_num(z, atom.formal_charge)
208    } else {
209        z
210    };
211    let dv = default_valence(eff_z);
212    let valens = valences_of(eff_z);
213
214    // `dv >= 0` 把无价约束的元素挡在外面,见模块文档第 2 点
215    if dv >= 0 && accum > dv as f32 && is_aromatic_atom(mol, idx) {
216        let mut pval = dv;
217        for &val in valens {
218            let val = i32::from(val);
219            if val == -1 || val as f32 > accum {
220                break;
221            }
222            pval = val;
223        }
224        // 差在 1.5 以内就取该价态 —— 针对 c1cccn1C 这类"按芳香键算像 4 价、
225        // kekulize 之后其实是 3 价"的芳香原子
226        if accum - pval as f32 <= 1.5 {
227            accum = pval as f32;
228        }
229    }
230
231    // x.5 要向上取整(1.5 → 2):加 0.1 再四舍五入
232    accum += 0.1;
233    let res = accum.round() as i32;
234
235    // -- 严格校验(strict = false 时整段跳过,与 C++ 的 `if (strict || checkIt)` 一致)--
236    if !strict {
237        return Ok(res);
238    }
239    let mut max_valence = last_valence(eff_z);
240    let mut offset = 0i32;
241    if can_be_hypervalent(z, eff_z) {
242        max_valence = last_valence(z);
243        offset -= i32::from(atom.formal_charge);
244    }
245    // 历史遗留:双配位的 [H-] 一直被接受
246    if z == 1 && atom.formal_charge == -1 {
247        max_valence = 2;
248    }
249    // max_valence == -1 表示高端不设限
250    if max_valence >= 0 && last_valence(z) >= 0 && (res + offset) > max_valence {
251        return Err(ValenceError {
252            atom: idx,
253            symbol: element::by_atomic_num(z).map_or("?", |e| e.symbol),
254            valence: res,
255            kind: ValenceErrorKind::ExplicitValenceTooHigh,
256        });
257    }
258
259    Ok(res)
260}
261
262/// 非严格模式的隐式氢数:跳过校验,永不失败。
263#[must_use]
264pub fn implicit_hs_nonstrict(mol: &MolBuilder, idx: u32, ev: i32) -> u8 {
265    implicit_hs_of(mol, idx, ev, false).unwrap_or(0)
266}
267
268/// 非严格模式的总价 = 显式价 + 隐式氢数。
269///
270/// Kekulize 用它做前后快照比对:kekulize 不应改变任何原子的总价。
271#[must_use]
272pub fn total_valence_nonstrict(mol: &MolBuilder, idx: u32) -> i32 {
273    let ev = explicit_valence_nonstrict(mol, idx);
274    ev + i32::from(implicit_hs_nonstrict(mol, idx, ev))
275}
276
277/// 计算隐式氢数。`strict` 为假时不返回错误。
278///
279/// # Errors
280/// `strict` 为真且该原子的价态不合法时返回 [`ValenceError`]。
281pub fn implicit_hs_of(
282    mol: &MolBuilder,
283    idx: u32,
284    ev: i32,
285    strict: bool,
286) -> Result<u8, ValenceError> {
287    if mol.atoms()[idx as usize]
288        .flags
289        .contains(AtomFlags::NO_IMPLICIT)
290    {
291        return Ok(0);
292    }
293    implicit_hs_inner(mol, idx, ev, strict)
294}
295
296/// [`implicit_hs_of`] 的内核,**不看** `NO_IMPLICIT`。
297///
298/// 那个标志说的是"这个原子写在方括号里,氢数写死了",而裸写形式那一档问的
299/// 恰恰是"没有方括号时会补几个氢" —— 见 [`implicit_hs_for_bare_form`]。
300fn implicit_hs_inner(
301    mol: &MolBuilder,
302    idx: u32,
303    ev: i32,
304    strict: bool,
305) -> Result<u8, ValenceError> {
306    let atom = mol.atoms()[idx as usize];
307    let z = atom.atomic_num;
308    if z == 0 {
309        return Ok(0); // 通配原子 `*`
310    }
311
312    // 自由基电子数由第 6 步 [`assign_radicals`](crate::radicals::assign_radicals)
313    // 填充。净化管线里第 6 步排在第 3 步之后,所以这里读到的必然是 0;
314    // 但读字段而不是写死 0 —— 用户在净化之后再调一次本函数时,
315    // 拿到的才是正确结果。
316    let n_radicals = i32::from(atom.num_radical_electrons);
317
318    // 氢的特殊分支
319    if ev == 0 && n_radicals == 0 && z == 1 {
320        return match atom.formal_charge {
321            1 | -1 => Ok(0),
322            0 => Ok(1),
323            _ if strict => Err(ValenceError {
324                atom: idx,
325                symbol: "H",
326                valence: ev,
327                kind: ValenceErrorKind::UnreasonableFormalCharge,
328            }),
329            _ => Ok(0),
330        };
331    }
332
333    let mut explicit_plus_rad = ev + n_radicals;
334
335    let ovalens = valences_of(z);
336    let mut eff_z = if ovalens.len() > 1 || ovalens[0] != -1 {
337        effective_atomic_num(z, atom.formal_charge)
338    } else {
339        z
340    };
341    if eff_z == 0 {
342        return Ok(0);
343    }
344
345    // 注意:`dv` 取自**调整前**的 eff_z,`valens` 取自**调整后**的 —— 见模块文档第 1 点
346    let dv = default_valence(eff_z);
347    if dv == -1 {
348        return Ok(0); // d 区 / f 区元素无默认价
349    }
350    if can_be_hypervalent(z, eff_z) {
351        eff_z = z;
352        explicit_plus_rad -= i32::from(atom.formal_charge);
353    }
354    let valens = valences_of(eff_z);
355
356    let res: i32 = if is_aromatic_atom(mol, idx) {
357        if explicit_plus_rad <= dv {
358            dv - explicit_plus_rad
359        } else {
360            // 芳香原子被假定已处于某个允许的价态,不再补氢
361            let satisfied = valens
362                .iter()
363                .map(|&v| i32::from(v))
364                .take_while(|&v| v > 0)
365                .any(|v| explicit_plus_rad == v);
366            if !satisfied && strict {
367                return Err(ValenceError {
368                    atom: idx,
369                    symbol: element::by_atomic_num(z).map_or("?", |e| e.symbol),
370                    valence: ev,
371                    kind: ValenceErrorKind::AromaticValenceNotAllowed,
372                });
373            }
374            0
375        }
376    } else {
377        // 非芳香:允许非默认价,取下一个不小于当前价的允许价态
378        let found = valens
379            .iter()
380            .map(|&v| i32::from(v))
381            .take_while(|&v| v >= 0)
382            .find(|&v| explicit_plus_rad <= v);
383        match found {
384            Some(v) => v - explicit_plus_rad,
385            None => {
386                if strict && last_valence(eff_z) != -1 && last_valence(z) > 0 {
387                    return Err(ValenceError {
388                        atom: idx,
389                        symbol: element::by_atomic_num(z).map_or("?", |e| e.symbol),
390                        valence: ev,
391                        kind: ValenceErrorKind::ExplicitValenceTooHigh,
392                    });
393                }
394                0
395            }
396        }
397    };
398
399    Ok(u8::try_from(res.max(0)).unwrap_or(0))
400}
401
402/// 裸写形式(去掉方括号)被读者读回来时,这个原子会补几个氢。
403///
404/// # 这是**写出侧**必须问的问题
405///
406/// 去掉方括号之后,氢数由**读者**按本模块这条规则反推。写出侧要保证反推出来的
407/// 数与实际相等,才敢去框。算多了只是多留几个框(啰嗦);**算少了写出的是
408/// 另一个分子**。
409///
410/// 先前写出侧自己写了一份近似规则,而且注释里明写着"一处已知的不同步:
411/// `explicit_valence_of` 还有一步芳香价回落,这边没有"。两处各写一遍必然
412/// 静默分岔 —— 所以规则只留这一份,写出侧调它。
413///
414/// # 与 [`implicit_hs_of`] 的差别只在**输入**,不在规则
415///
416/// 裸写形式没有方括号,于是:没有 `NO_IMPLICIT` 标志、没有写死的氢数,
417/// 价里只剩键级和。
418///
419/// # 表达不出来的一律返回 `None`
420///
421/// 形式电荷、同位素、自由基裸写形式**写不出来**,这类原子根本不该去框。
422/// 返回 `None` 而不是"算一个数出来",省得调用方拿它当真。
423#[must_use]
424pub fn implicit_hs_for_bare_form(mol: &MolBuilder, idx: u32) -> Option<u8> {
425    let atom = mol.atoms()[idx as usize];
426    if atom.formal_charge != 0 || atom.num_radical_electrons != 0 || atom.isotope != 0 {
427        return None;
428    }
429    let ev = explicit_valence_with(mol, idx, 0.0, false).ok()?;
430    implicit_hs_inner(mol, idx, ev, false).ok()
431}