Skip to main content

qualia_core_db/domains/chemical/
organic_chemistry.rs

1//! Organic Chemistry Engine.
2//!
3//! Pure-Rust implementations of core organic-chemistry primitives:
4//! - SMILES parsing & molecular graph building
5//! - Molecular formula, exact weight, and isotope-aware mass
6//! - Lipinski Rule-of-Five, Veber, Ghose, Egan drug-likeness filters
7//! - LogP (Crippen–Wildman atomic contributions, 25 atom types)
8//! - TPSA (Ertl 2000 atomic contributions)
9//! - H-bond donors / acceptors, rotatable bonds, aromatic ring count
10//! - Functional group detection (20 groups via SMARTS-inspired pattern matching)
11//! - Chiral centre enumeration
12//! - Morgan circular fingerprint generation
13//! - Thermochemistry: Arrhenius, Gibbs–Helmholtz, van't Hoff, Henderson–Hasselbalch
14//! - Green chemistry: atom economy, E-factor, PMI, RME, yield-adjusted AE
15//! - pKa estimation (functional-group based)
16//! - SMILES structural validation
17//! - InChI / InChIKey format validation
18//!
19//! SHACL constraint name → function:
20//!   `qualia:validateSmiles`              → `validate_smiles()`
21//!   `qualia:validateInchi`               → `validate_inchi()`
22//!   `qualia:computeMolecularWeight`      → `exact_molecular_weight()`
23//!   `qualia:computeLogP`                 → `compute_logp()`
24//!   `qualia:computeTPSA`                 → `compute_tpsa()`
25//!   `qualia:evaluateLipinski`            → `evaluate_lipinski()`
26//!   `qualia:evaluateVeber`               → `evaluate_veber()`
27//!   `qualia:evaluateGhose`               → `evaluate_ghose()`
28//!   `qualia:evaluateEgan`                → `evaluate_egan()`
29//!   `qualia:detectFunctionalGroups`      → `detect_functional_groups()`
30//!   `qualia:computePka`                  → `estimate_pka()`
31//!   `qualia:computeChiralCenters`        → `count_chiral_centers()`
32//!   `qualia:generateCircularFingerprint` → `circular_fingerprint()`
33//!   `qualia:computeArrheniusRate`        → `arrhenius_rate()`
34//!   `qualia:computeGibbsEnergy`          → `gibbs_free_energy()`
35//!   `qualia:computeEquilibrium`          → `equilibrium_constant()`
36//!   `qualia:computeHendersonHasselbalch` → `henderson_hasselbalch()`
37//!   `qualia:computeAtomEconomy`          → `atom_economy()`
38//!   `qualia:computeEFactor`              → `e_factor()`
39//!   `qualia:computeGreenMetrics`         → `green_metrics()`
40
41// ─── Physical constants ───────────────────────────────────────────────────────
42
43/// Universal gas constant J / (mol·K)
44pub const R_J_MOL_K: f64 = 8.314_462_618;
45
46// ─── Atomic data ──────────────────────────────────────────────────────────────
47
48/// Monoisotopic / standard atomic weights (IUPAC 2021).
49const ATOMIC_WEIGHTS: &[(&str, f64)] = &[
50    ("H", 1.00794),
51    ("B", 10.811),
52    ("C", 12.011),
53    ("N", 14.007),
54    ("O", 15.999),
55    ("F", 18.998),
56    ("P", 30.974),
57    ("S", 32.06),
58    ("Cl", 35.45),
59    ("Br", 79.904),
60    ("I", 126.904),
61    ("Si", 28.085),
62    ("As", 74.922),
63    ("Se", 78.971),
64    ("Te", 127.6),
65];
66
67fn atomic_weight(symbol: &str) -> f64 {
68    ATOMIC_WEIGHTS
69        .iter()
70        .find(|(s, _)| *s == symbol)
71        .map(|(_, w)| *w)
72        .unwrap_or(12.0) // default C
73}
74
75/// Default valences for the organic-subset elements.
76const VALENCES: &[(&str, u8)] = &[
77    ("C", 4),
78    ("N", 3),
79    ("O", 2),
80    ("S", 2),
81    ("S", 4),
82    ("S", 6),
83    ("P", 3),
84    ("P", 5),
85    ("F", 1),
86    ("Cl", 1),
87    ("Br", 1),
88    ("I", 1),
89    ("B", 3),
90    ("Si", 4),
91    ("Se", 2),
92    ("As", 3),
93];
94
95fn default_valence(symbol: &str) -> u8 {
96    VALENCES
97        .iter()
98        .find(|(s, _)| *s == symbol)
99        .map(|(_, v)| *v)
100        .unwrap_or(4)
101}
102
103// ─── Molecule representation ──────────────────────────────────────────────────
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum BondOrder {
107    Single,
108    Double,
109    Triple,
110    Aromatic,
111}
112
113#[derive(Debug, Clone)]
114pub struct Bond {
115    pub atom_a: usize,
116    pub atom_b: usize,
117    pub order: BondOrder,
118    /// Set to true once ring-detection pass identifies this bond as part of a cycle.
119    pub in_ring: bool,
120}
121
122#[derive(Debug, Clone)]
123pub struct Atom {
124    pub element: String,
125    pub is_aromatic: bool,
126    pub charge: i8,
127    pub isotope: Option<u16>,
128    pub explicit_h: u8,
129    pub n_implicit_h: u8,
130    /// Number of heavy-atom bonds (computed after parsing).
131    pub degree: u8,
132    pub idx: usize,
133}
134
135#[derive(Debug, Clone)]
136pub struct Molecule {
137    pub smiles: String,
138    pub atoms: Vec<Atom>,
139    pub bonds: Vec<Bond>,
140    pub is_valid: bool,
141    pub error: Option<String>,
142}
143
144// ─── SMILES parser ────────────────────────────────────────────────────────────
145
146/// Parse a SMILES string into a `Molecule`.
147pub fn parse_smiles(smiles: &str) -> Molecule {
148    let chars: Vec<char> = smiles.chars().collect();
149    let n = chars.len();
150    let mut atoms: Vec<Atom> = Vec::new();
151    let mut bonds: Vec<Bond> = Vec::new();
152    let mut branch_stack: Vec<usize> = Vec::new();
153    let mut ring_map: std::collections::HashMap<u16, (usize, Option<BondOrder>)> =
154        std::collections::HashMap::new();
155    let mut current_atom: Option<usize> = None;
156    let mut pending_bond: Option<BondOrder> = None;
157    let mut pos = 0;
158    let mut error: Option<String> = None;
159
160    macro_rules! add_atom {
161        ($elem:expr, $aromatic:expr, $charge:expr, $isotope:expr, $explicit_h:expr) => {{
162            let idx = atoms.len();
163            let atom = Atom {
164                element: $elem.to_string(),
165                is_aromatic: $aromatic,
166                charge: $charge,
167                isotope: $isotope,
168                explicit_h: $explicit_h,
169                n_implicit_h: 0,
170                degree: 0,
171                idx,
172            };
173            atoms.push(atom);
174            if let Some(from) = current_atom {
175                let bond_order = pending_bond.take().unwrap_or_else(|| {
176                    if atoms[from].is_aromatic && $aromatic {
177                        BondOrder::Aromatic
178                    } else {
179                        BondOrder::Single
180                    }
181                });
182                bonds.push(Bond {
183                    atom_a: from,
184                    atom_b: idx,
185                    order: bond_order,
186                    in_ring: false,
187                });
188                atoms[from].degree += 1;
189                atoms[idx].degree += 1;
190            } else {
191                pending_bond = None;
192            }
193            current_atom = Some(idx);
194        }};
195    }
196
197    while pos < n {
198        let c = chars[pos];
199        match c {
200            // ── Branch open/close ─────────────────────────────────────────
201            '(' => {
202                if let Some(idx) = current_atom {
203                    branch_stack.push(idx);
204                }
205                pos += 1;
206            }
207            ')' => {
208                current_atom = branch_stack.pop();
209                pending_bond = None;
210                pos += 1;
211            }
212            // ── Explicit bond tokens ──────────────────────────────────────
213            '-' => {
214                pending_bond = Some(BondOrder::Single);
215                pos += 1;
216            }
217            '=' => {
218                pending_bond = Some(BondOrder::Double);
219                pos += 1;
220            }
221            '#' => {
222                pending_bond = Some(BondOrder::Triple);
223                pos += 1;
224            }
225            ':' => {
226                pending_bond = Some(BondOrder::Aromatic);
227                pos += 1;
228            }
229            // ── Disconnected structure ────────────────────────────────────
230            '.' => {
231                current_atom = None;
232                pending_bond = None;
233                pos += 1;
234            }
235            // ── Ring closures: single digit ───────────────────────────────
236            '0'..='9' => {
237                let rn = c as u16 - b'0' as u16;
238                close_or_open_ring(
239                    &mut ring_map,
240                    &mut bonds,
241                    &mut atoms,
242                    rn,
243                    current_atom,
244                    &mut pending_bond,
245                );
246                pos += 1;
247            }
248            // ── Ring closures: %nn ────────────────────────────────────────
249            '%' if pos + 2 < n => {
250                if let (Some(d1), Some(d2)) =
251                    (chars[pos + 1].to_digit(10), chars[pos + 2].to_digit(10))
252                {
253                    let rn = (d1 * 10 + d2) as u16;
254                    close_or_open_ring(
255                        &mut ring_map,
256                        &mut bonds,
257                        &mut atoms,
258                        rn,
259                        current_atom,
260                        &mut pending_bond,
261                    );
262                    pos += 3;
263                } else {
264                    pos += 1;
265                }
266            }
267            // ── Bracketed atom [isotope?Symbol charge? Hn?] ──────────────
268            '[' => {
269                let (atom_elem, aromatic, charge, isotope, expl_h, advance) =
270                    parse_bracket_atom(&chars, pos);
271                add_atom!(atom_elem, aromatic, charge, isotope, expl_h);
272                pos += advance;
273            }
274            // ── Two-letter organic-subset atoms ───────────────────────────
275            'C' if pos + 1 < n && chars[pos + 1] == 'l' => {
276                add_atom!("Cl", false, 0, None, 0);
277                pos += 2;
278            }
279            'B' if pos + 1 < n && chars[pos + 1] == 'r' => {
280                add_atom!("Br", false, 0, None, 0);
281                pos += 2;
282            }
283            // ── Single-letter organic subset ──────────────────────────────
284            'C' => {
285                add_atom!("C", false, 0, None, 0);
286                pos += 1;
287            }
288            'c' => {
289                add_atom!("C", true, 0, None, 0);
290                pos += 1;
291            }
292            'N' => {
293                add_atom!("N", false, 0, None, 0);
294                pos += 1;
295            }
296            'n' => {
297                add_atom!("N", true, 0, None, 0);
298                pos += 1;
299            }
300            'O' => {
301                add_atom!("O", false, 0, None, 0);
302                pos += 1;
303            }
304            'o' => {
305                add_atom!("O", true, 0, None, 0);
306                pos += 1;
307            }
308            'S' => {
309                add_atom!("S", false, 0, None, 0);
310                pos += 1;
311            }
312            's' => {
313                add_atom!("S", true, 0, None, 0);
314                pos += 1;
315            }
316            'P' => {
317                add_atom!("P", false, 0, None, 0);
318                pos += 1;
319            }
320            'p' => {
321                add_atom!("P", true, 0, None, 0);
322                pos += 1;
323            }
324            'F' => {
325                add_atom!("F", false, 0, None, 0);
326                pos += 1;
327            }
328            'B' => {
329                add_atom!("B", false, 0, None, 0);
330                pos += 1;
331            }
332            'I' => {
333                add_atom!("I", false, 0, None, 0);
334                pos += 1;
335            }
336            _ => {
337                pos += 1;
338            }
339        }
340    }
341
342    // Validate unclosed rings
343    if !ring_map.is_empty() {
344        error = Some(format!(
345            "Unclosed ring bonds: {:?}",
346            ring_map.keys().collect::<Vec<_>>()
347        ));
348    }
349
350    // Compute implicit hydrogens and adjacency
351    fill_implicit_hydrogens(&mut atoms, &bonds);
352    // Mark ring bonds via DFS
353    mark_ring_bonds(&atoms, &mut bonds);
354
355    Molecule {
356        smiles: smiles.to_string(),
357        is_valid: error.is_none() && !atoms.is_empty(),
358        error,
359        atoms,
360        bonds,
361    }
362}
363
364fn close_or_open_ring(
365    ring_map: &mut std::collections::HashMap<u16, (usize, Option<BondOrder>)>,
366    bonds: &mut Vec<Bond>,
367    atoms: &mut Vec<Atom>,
368    ring_num: u16,
369    current_atom: Option<usize>,
370    pending_bond: &mut Option<BondOrder>,
371) {
372    let from_atom = match current_atom {
373        Some(i) => i,
374        None => return,
375    };
376    if let Some((open_idx, open_bond)) = ring_map.remove(&ring_num) {
377        let order = pending_bond
378            .take()
379            .or(open_bond)
380            .unwrap_or(BondOrder::Single);
381        bonds.push(Bond {
382            atom_a: open_idx,
383            atom_b: from_atom,
384            order,
385            in_ring: true,
386        });
387        atoms[open_idx].degree += 1;
388        atoms[from_atom].degree += 1;
389    } else {
390        ring_map.insert(ring_num, (from_atom, pending_bond.take()));
391    }
392}
393
394/// Parse a bracketed atom [isotope?Symbol charge? Hn?]. Returns (elem, aromatic, charge, isotope, explicit_h, chars_consumed).
395fn parse_bracket_atom(
396    chars: &[char],
397    start: usize,
398) -> (&'static str, bool, i8, Option<u16>, u8, usize) {
399    let mut pos = start + 1; // skip '['
400    let end = chars.len();
401
402    // Isotope
403    let mut isotope_str = String::new();
404    while pos < end && chars[pos].is_ascii_digit() {
405        isotope_str.push(chars[pos]);
406        pos += 1;
407    }
408    let isotope: Option<u16> = isotope_str.parse().ok();
409
410    // Element symbol (1-2 chars, first uppercase)
411    let sym_start = pos;
412    if pos < end {
413        pos += 1;
414    }
415    if pos < end && chars[pos].is_ascii_lowercase() && chars[pos] != 'h' {
416        pos += 1;
417    }
418    let sym: String = chars[sym_start..pos].iter().collect();
419
420    // Aromaticity: if symbol is lowercase that's already handled by aromatic atoms above;
421    // inside brackets, aromatic lowercase atoms are also valid
422    let is_aromatic = sym_start < chars.len() && chars[sym_start].is_ascii_lowercase();
423
424    // Explicit H count
425    let mut explicit_h = 0u8;
426    if pos < end && chars[pos] == 'H' {
427        pos += 1;
428        if pos < end && chars[pos].is_ascii_digit() {
429            explicit_h = chars[pos] as u8 - b'0';
430            pos += 1;
431        } else {
432            explicit_h = 1;
433        }
434    }
435
436    // Charge
437    let mut charge = 0i8;
438    if pos < end && (chars[pos] == '+' || chars[pos] == '-') {
439        let sign: i8 = if chars[pos] == '+' { 1 } else { -1 };
440        pos += 1;
441        if pos < end && chars[pos].is_ascii_digit() {
442            charge = sign * (chars[pos] as i8 - b'0' as i8);
443            pos += 1;
444        } else if pos < end && (chars[pos] == '+' || chars[pos] == '-') {
445            charge = sign * 2;
446            pos += 1; // e.g. ++ or --
447        } else {
448            charge = sign;
449        }
450    }
451
452    // Skip to closing ']'
453    while pos < end && chars[pos] != ']' {
454        pos += 1;
455    }
456    let advance = pos - start + 1; // +1 for ']'
457
458    // Map symbol to &'static str
459    let elem: &'static str = match sym.to_uppercase().as_str() {
460        "C" => "C",
461        "N" => "N",
462        "O" => "O",
463        "S" => "S",
464        "P" => "P",
465        "F" => "F",
466        "CL" => "Cl",
467        "BR" => "Br",
468        "I" => "I",
469        "B" => "B",
470        "SI" => "Si",
471        "AS" => "As",
472        "SE" => "Se",
473        "TE" => "Te",
474        "H" => "H",
475        _ => "C",
476    };
477
478    (elem, is_aromatic, charge, isotope, explicit_h, advance)
479}
480
481fn fill_implicit_hydrogens(atoms: &mut Vec<Atom>, bonds: &[Bond]) {
482    // valence_used: sum of bond orders (double=2, triple=3, single/aromatic=1)
483    // connectivity: number of distinct bond partners (for degree field)
484    let mut valence_used = vec![0i16; atoms.len()];
485    let mut connectivity = vec![0u8; atoms.len()];
486
487    for b in bonds {
488        let order: i16 = match b.order {
489            BondOrder::Double => 2,
490            BondOrder::Triple => 3,
491            BondOrder::Single | BondOrder::Aromatic => 1,
492        };
493        valence_used[b.atom_a] += order;
494        valence_used[b.atom_b] += order;
495        connectivity[b.atom_a] += 1;
496        connectivity[b.atom_b] += 1;
497    }
498
499    for atom in atoms.iter_mut() {
500        if atom.element == "H" {
501            atom.n_implicit_h = 0;
502            atom.degree = connectivity[atom.idx];
503            continue;
504        }
505        let valence = default_valence(&atom.element) as i16;
506        let charge_adj = atom.charge as i16;
507        // Aromatic atoms: the delocalized pi electron occupies one valence slot.
508        // This matches the standard SMILES spec (c in benzene → 1 implicit H, not 2).
509        let aromatic_adj: i16 = if atom.is_aromatic { 1 } else { 0 };
510        let used = valence_used[atom.idx] + atom.explicit_h as i16 + aromatic_adj;
511        atom.n_implicit_h = (valence + charge_adj - used).max(0) as u8;
512        atom.degree = connectivity[atom.idx];
513    }
514}
515
516/// DFS-based ring bond marking (sets `Bond::in_ring`).
517fn mark_ring_bonds(atoms: &[Atom], bonds: &mut Vec<Bond>) {
518    // Bonds that close rings were already flagged in_ring during parsing.
519    // For any remaining ring bonds (where both endpoints are in a cycle),
520    // we do a simple DFS to find back-edges.
521    let n = atoms.len();
522    let mut adj: Vec<Vec<(usize, usize)>> = vec![vec![]; n]; // (neighbour, bond_idx)
523    for (bi, b) in bonds.iter().enumerate() {
524        adj[b.atom_a].push((b.atom_b, bi));
525        adj[b.atom_b].push((b.atom_a, bi));
526    }
527    let mut visited = vec![false; n];
528    let mut parent = vec![usize::MAX; n];
529    for start in 0..n {
530        if !visited[start] {
531            dfs_mark_rings(&adj, bonds, &mut visited, &mut parent, start, usize::MAX);
532        }
533    }
534}
535
536fn dfs_mark_rings(
537    adj: &[Vec<(usize, usize)>],
538    bonds: &mut Vec<Bond>,
539    visited: &mut Vec<bool>,
540    parent: &mut Vec<usize>,
541    u: usize,
542    par: usize,
543) {
544    visited[u] = true;
545    parent[u] = par;
546    for &(v, bi) in &adj[u] {
547        if !visited[v] {
548            dfs_mark_rings(adj, bonds, visited, parent, v, u);
549        } else if v != par {
550            bonds[bi].in_ring = true;
551        }
552    }
553}
554
555// ─── Molecular formula & weight ──────────────────────────────────────────────
556
557/// Atom counts by element symbol.
558pub fn molecular_formula(mol: &Molecule) -> std::collections::HashMap<String, u32> {
559    let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
560    for atom in &mol.atoms {
561        *counts.entry(atom.element.clone()).or_insert(0) += 1;
562        if atom.n_implicit_h + atom.explicit_h > 0 {
563            *counts.entry("H".to_string()).or_insert(0) +=
564                (atom.n_implicit_h + atom.explicit_h) as u32;
565        }
566    }
567    counts
568}
569
570/// Hill-order formula string (C first, H second, then others alphabetically).
571pub fn formula_string(mol: &Molecule) -> String {
572    let counts = molecular_formula(mol);
573    let mut parts: Vec<(String, u32)> = counts.into_iter().collect();
574    parts.sort_by_key(|(e, _)| match e.as_str() {
575        "C" => 0,
576        "H" => 1,
577        _ => 2,
578    });
579    parts
580        .iter()
581        .map(|(e, n)| {
582            if *n == 1 {
583                e.clone()
584            } else {
585                format!("{}{}", e, n)
586            }
587        })
588        .collect()
589}
590
591/// Exact monoisotopic molecular weight (Da).
592pub fn exact_molecular_weight(mol: &Molecule) -> f64 {
593    let counts = molecular_formula(mol);
594    counts
595        .iter()
596        .map(|(e, &n)| atomic_weight(e) * n as f64)
597        .sum()
598}
599
600// ─── Lipinski / drug-likeness descriptors ────────────────────────────────────
601
602#[derive(Debug, Clone)]
603pub struct MolecularDescriptors {
604    pub molecular_weight: f64,
605    pub formula: String,
606    pub heavy_atom_count: usize,
607    pub hb_donors: u32,
608    pub hb_acceptors: u32,
609    pub rotatable_bonds: u32,
610    pub aromatic_ring_count: u32,
611    pub ring_count: u32,
612    pub logp_crippen: f64,
613    pub tpsa_ertl: f64,
614    pub chiral_centers: u32,
615    pub fraction_csp3: f64,
616}
617
618/// Compute all Lipinski/Veber/Ghose descriptors from a parsed molecule.
619pub fn compute_descriptors(mol: &Molecule) -> MolecularDescriptors {
620    let mw = exact_molecular_weight(mol);
621    let formula = formula_string(mol);
622    let heavy = mol.atoms.iter().filter(|a| a.element != "H").count();
623
624    // H-bond donors: NH and OH groups
625    let hbd = mol
626        .atoms
627        .iter()
628        .filter(|a| (a.element == "N" || a.element == "O") && (a.n_implicit_h + a.explicit_h) > 0)
629        .count() as u32;
630
631    // H-bond acceptors: all N and O (Lipinski: just N+O count)
632    let hba = mol
633        .atoms
634        .iter()
635        .filter(|a| a.element == "N" || a.element == "O")
636        .count() as u32;
637
638    // Rotatable bonds: single, non-ring, both atoms have degree ≥ 2, not amide/ester
639    let adj = build_adj(mol);
640    let rot = mol
641        .bonds
642        .iter()
643        .filter(|b| {
644            b.order == BondOrder::Single
645                && !b.in_ring
646                && mol.atoms[b.atom_a].element != "H"
647                && mol.atoms[b.atom_b].element != "H"
648                && mol.atoms[b.atom_a].degree >= 2
649                && mol.atoms[b.atom_b].degree >= 2
650        })
651        .count() as u32;
652
653    // Aromatic rings (simplified: number of aromatic-atom-containing SSSR cycles)
654    let aro_ring = count_aromatic_rings(&mol.atoms, &mol.bonds);
655    let ring_ct = count_all_rings(&mol.bonds);
656
657    // LogP Crippen simplified
658    let logp = compute_logp(mol);
659
660    // TPSA Ertl
661    let tpsa = compute_tpsa(mol);
662
663    // Chiral centers
664    let chiral = count_chiral_centers(mol);
665
666    // Fraction of sp3 carbons
667    let n_c = mol.atoms.iter().filter(|a| a.element == "C").count();
668    let n_csp3 = mol
669        .atoms
670        .iter()
671        .filter(|a| a.element == "C" && !a.is_aromatic && !is_sp2_carbon(a, &adj, mol))
672        .count();
673    let fsp3 = if n_c > 0 {
674        n_csp3 as f64 / n_c as f64
675    } else {
676        0.0
677    };
678
679    MolecularDescriptors {
680        molecular_weight: mw,
681        formula,
682        heavy_atom_count: heavy,
683        hb_donors: hbd,
684        hb_acceptors: hba,
685        rotatable_bonds: rot,
686        aromatic_ring_count: aro_ring,
687        ring_count: ring_ct,
688        logp_crippen: logp,
689        tpsa_ertl: tpsa,
690        chiral_centers: chiral,
691        fraction_csp3: fsp3,
692    }
693}
694
695fn build_adj(mol: &Molecule) -> Vec<Vec<usize>> {
696    let mut adj = vec![vec![]; mol.atoms.len()];
697    for b in &mol.bonds {
698        adj[b.atom_a].push(b.atom_b);
699        adj[b.atom_b].push(b.atom_a);
700    }
701    adj
702}
703
704fn is_sp2_carbon(a: &Atom, _adj: &[Vec<usize>], mol: &Molecule) -> bool {
705    mol.bonds.iter().any(|b| {
706        (b.atom_a == a.idx || b.atom_b == a.idx)
707            && (b.order == BondOrder::Double || b.order == BondOrder::Aromatic)
708    })
709}
710
711fn count_aromatic_rings(_atoms: &[Atom], bonds: &[Bond]) -> u32 {
712    // Simplified: each aromatic bond that closes a ring contributes to one ring
713    let ring_bonds = bonds
714        .iter()
715        .filter(|b| b.in_ring && b.order == BondOrder::Aromatic)
716        .count();
717    // Rough: 6-membered ring has 6 aromatic bonds; 5-membered has 5
718    let estimated_from_bonds = (ring_bonds / 6 + (ring_bonds % 6 > 2) as usize) as u32;
719    if estimated_from_bonds > 0 {
720        return estimated_from_bonds;
721    }
722
723    // Fallback for heteroaromatic systems that parse with aromatic atoms but
724    // without enough explicit aromatic ring bonds to satisfy the coarse rule above.
725    let aromatic_ring_atoms = _atoms.iter().filter(|a| a.is_aromatic).count();
726    if aromatic_ring_atoms >= 5 {
727        1 + ((aromatic_ring_atoms.saturating_sub(10)) / 5) as u32
728    } else {
729        0
730    }
731}
732
733fn count_all_rings(bonds: &[Bond]) -> u32 {
734    bonds.iter().filter(|b| b.in_ring).count() as u32 / 3 // rough heuristic
735}
736
737// ─── LogP (Crippen–Wildman simplified) ───────────────────────────────────────
738
739/// Per-atom LogP contributions mapped by (element, aromaticity, polar_neighbour).
740/// Derived from Wildman & Crippen 1999, Table 1 (25 of 68 atom types).
741pub fn compute_logp(mol: &Molecule) -> f64 {
742    let adj = build_adj(mol);
743    let mut total = 0.0_f64;
744
745    for atom in &mol.atoms {
746        if atom.element == "H" {
747            continue;
748        }
749        let neighbours: Vec<&Atom> = adj[atom.idx].iter().map(|&i| &mol.atoms[i]).collect();
750        let has_polar_nbr = neighbours.iter().any(|n| {
751            matches!(
752                n.element.as_str(),
753                "N" | "O" | "S" | "P" | "F" | "Cl" | "Br" | "I"
754            )
755        });
756        let is_carbonyl = mol.bonds.iter().any(|b| {
757            (b.atom_a == atom.idx || b.atom_b == atom.idx) && b.order == BondOrder::Double && {
758                let other = if b.atom_a == atom.idx {
759                    b.atom_b
760                } else {
761                    b.atom_a
762                };
763                mol.atoms[other].element == "O"
764            }
765        });
766
767        let contrib = match atom.element.as_str() {
768            "C" => {
769                if atom.is_aromatic {
770                    if has_polar_nbr {
771                        0.0782
772                    } else {
773                        0.2140
774                    }
775                } else if is_carbonyl {
776                    0.0000
777                } else if atom.charge != 0 {
778                    -0.3537
779                } else if has_polar_nbr {
780                    0.0000
781                } else {
782                    0.1441
783                }
784            }
785            "N" => {
786                if atom.charge > 0 {
787                    -1.9513
788                } else if atom.is_aromatic {
789                    -0.7096
790                } else if is_adjacent_amide(atom, mol) {
791                    -1.0280
792                } else {
793                    -1.0190
794                }
795            }
796            "O" => {
797                if atom.charge < 0 {
798                    -1.0810
799                } else if is_carbonyl_oxygen(atom, mol) {
800                    0.1421
801                } else if atom.is_aromatic {
802                    -0.0582
803                } else {
804                    -0.4670
805                }
806            }
807            "S" => {
808                if atom.is_aromatic {
809                    0.4880
810                } else {
811                    0.5620
812                }
813            }
814            "P" => 0.8760,
815            "F" => 0.1480,
816            "Cl" => 0.3010,
817            "Br" => 0.6130,
818            "I" => 1.2570,
819            "B" => 0.1758,
820            "Si" => 0.2756,
821            _ => 0.0,
822        };
823        total += contrib;
824    }
825    // Add implicit-H contribution (CH contribution ~0.1441 per CH3 group simplified)
826    for atom in &mol.atoms {
827        if atom.element == "C" && !atom.is_aromatic {
828            total += 0.1441 * (atom.n_implicit_h.min(3)) as f64 * 0.3;
829        }
830    }
831    total
832}
833
834fn is_adjacent_amide(atom: &Atom, mol: &Molecule) -> bool {
835    mol.bonds.iter().any(|b| {
836        (b.atom_a == atom.idx || b.atom_b == atom.idx) && {
837            let other_idx = if b.atom_a == atom.idx {
838                b.atom_b
839            } else {
840                b.atom_a
841            };
842            let other = &mol.atoms[other_idx];
843            other.element == "C"
844                && mol.bonds.iter().any(|b2| {
845                    (b2.atom_a == other_idx || b2.atom_b == other_idx)
846                        && b2.order == BondOrder::Double
847                        && {
848                            let o = if b2.atom_a == other_idx {
849                                b2.atom_b
850                            } else {
851                                b2.atom_a
852                            };
853                            mol.atoms[o].element == "O"
854                        }
855                })
856        }
857    })
858}
859
860fn is_carbonyl_oxygen(atom: &Atom, mol: &Molecule) -> bool {
861    mol.bonds
862        .iter()
863        .any(|b| (b.atom_a == atom.idx || b.atom_b == atom.idx) && b.order == BondOrder::Double)
864}
865
866// ─── TPSA (Ertl 2000 atomic contributions) ───────────────────────────────────
867
868/// Topological polar surface area in Ų.
869pub fn compute_tpsa(mol: &Molecule) -> f64 {
870    let mut tpsa = 0.0_f64;
871    for atom in &mol.atoms {
872        let total_h = (atom.n_implicit_h + atom.explicit_h) as f64;
873        let contrib = match atom.element.as_str() {
874            "N" => {
875                if atom.charge > 0 {
876                    0.0
877                } else if atom.is_aromatic {
878                    12.89
879                } else if total_h >= 2.0 {
880                    26.02
881                }
882                // NH2
883                else if total_h >= 1.0 {
884                    16.61
885                }
886                // NH
887                else if is_adjacent_amide(atom, mol) {
888                    29.42
889                } else {
890                    11.49
891                } // tertiary N
892            }
893            "O" => {
894                if atom.charge != 0 {
895                    23.06
896                } else if atom.is_aromatic {
897                    13.14
898                } else if total_h >= 1.0 {
899                    20.23
900                }
901                // OH
902                else if is_carbonyl_oxygen(atom, mol) {
903                    17.07
904                } else {
905                    9.23
906                } // ether O
907            }
908            "S" => {
909                if atom.is_aromatic {
910                    0.0
911                } else if total_h >= 1.0 {
912                    38.80
913                }
914                // SH
915                else {
916                    32.09
917                } // sulfoxide / sulfone
918            }
919            "P" => 34.14,
920            _ => 0.0,
921        };
922        tpsa += contrib;
923    }
924    tpsa
925}
926
927// ─── Drug-likeness filters ────────────────────────────────────────────────────
928
929#[derive(Debug, Clone)]
930pub struct LipinskiResult {
931    /// MW ≤ 500 Da
932    pub mw_ok: bool,
933    /// LogP ≤ 5
934    pub logp_ok: bool,
935    /// H-bond donors ≤ 5
936    pub hbd_ok: bool,
937    /// H-bond acceptors ≤ 10
938    pub hba_ok: bool,
939    /// Number of rule violations (0 or 1 is oral-bioavailability acceptable).
940    pub violations: u8,
941    pub passes: bool,
942}
943
944pub fn evaluate_lipinski(desc: &MolecularDescriptors) -> LipinskiResult {
945    let mw_ok = desc.molecular_weight <= 500.0;
946    let logp_ok = desc.logp_crippen <= 5.0;
947    let hbd_ok = desc.hb_donors <= 5;
948    let hba_ok = desc.hb_acceptors <= 10;
949    let violations = (!mw_ok as u8) + (!logp_ok as u8) + (!hbd_ok as u8) + (!hba_ok as u8);
950    LipinskiResult {
951        mw_ok,
952        logp_ok,
953        hbd_ok,
954        hba_ok,
955        violations,
956        passes: violations <= 1,
957    }
958}
959
960#[derive(Debug, Clone)]
961pub struct VeberResult {
962    /// Rotatable bonds ≤ 10
963    pub rot_bonds_ok: bool,
964    /// TPSA ≤ 140 Ų
965    pub tpsa_ok: bool,
966    pub passes: bool,
967}
968
969pub fn evaluate_veber(desc: &MolecularDescriptors) -> VeberResult {
970    let rot_bonds_ok = desc.rotatable_bonds <= 10;
971    let tpsa_ok = desc.tpsa_ertl <= 140.0;
972    VeberResult {
973        rot_bonds_ok,
974        tpsa_ok,
975        passes: rot_bonds_ok && tpsa_ok,
976    }
977}
978
979#[derive(Debug, Clone)]
980pub struct GhoseResult {
981    /// 160 ≤ MW ≤ 480
982    pub mw_ok: bool,
983    /// -0.4 ≤ LogP ≤ 5.6
984    pub logp_ok: bool,
985    /// 20 ≤ heavy atoms ≤ 70
986    pub atoms_ok: bool,
987    /// Molar refractivity 40–130 (approximated from MW)
988    pub mr_ok: bool,
989    pub passes: bool,
990}
991
992pub fn evaluate_ghose(desc: &MolecularDescriptors) -> GhoseResult {
993    let mw_ok = desc.molecular_weight >= 160.0 && desc.molecular_weight <= 480.0;
994    let logp_ok = desc.logp_crippen >= -0.4 && desc.logp_crippen <= 5.6;
995    let atoms_ok = desc.heavy_atom_count >= 20 && desc.heavy_atom_count <= 70;
996    // MR ≈ 0.3285 × MW + 0.08 × logP + 1.0 (approximation)
997    let mr = 0.3285 * desc.molecular_weight + 0.08 * desc.logp_crippen + 1.0;
998    let mr_ok = mr >= 40.0 && mr <= 130.0;
999    let violations = (!mw_ok as u8) + (!logp_ok as u8) + (!atoms_ok as u8) + (!mr_ok as u8);
1000    GhoseResult {
1001        mw_ok,
1002        logp_ok,
1003        atoms_ok,
1004        mr_ok,
1005        passes: violations == 0,
1006    }
1007}
1008
1009#[derive(Debug, Clone)]
1010pub struct EganResult {
1011    /// TPSA ≤ 131.6 Ų
1012    pub tpsa_ok: bool,
1013    /// LogP ≤ 5.88
1014    pub logp_ok: bool,
1015    pub passes: bool,
1016}
1017
1018pub fn evaluate_egan(desc: &MolecularDescriptors) -> EganResult {
1019    let tpsa_ok = desc.tpsa_ertl <= 131.6;
1020    let logp_ok = desc.logp_crippen <= 5.88;
1021    EganResult {
1022        tpsa_ok,
1023        logp_ok,
1024        passes: tpsa_ok && logp_ok,
1025    }
1026}
1027
1028// ─── Functional group detection ───────────────────────────────────────────────
1029
1030#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1031pub enum FunctionalGroup {
1032    Hydroxyl,       // -OH
1033    PrimaryAmine,   // -NH2
1034    SecondaryAmine, // -NH-
1035    TertiaryAmine,  // -N<
1036    AromaticAmine,  // Ar-NH2
1037    Amide,          // -C(=O)N-
1038    CarboxylicAcid, // -C(=O)OH
1039    Ester,          // -C(=O)O-
1040    Ketone,         // -C(=O)-
1041    Aldehyde,       // -CHO
1042    Ether,          // -O-
1043    Thiol,          // -SH
1044    Sulfide,        // -S-
1045    Sulfonamide,    // -S(=O)2NH-
1046    Halide,         // -F, -Cl, -Br, -I
1047    AromaticRing,
1048    Nitrile,   // -C≡N
1049    Nitro,     // -NO2
1050    Phosphate, // -P(=O)(O)2
1051    Imidazole,
1052    GuanidiniumLike,
1053}
1054
1055/// Detect functional groups in a molecule. Returns unique set.
1056#[allow(non_snake_case)]
1057pub fn detect_functional_groups(mol: &Molecule) -> Vec<FunctionalGroup> {
1058    let mut found = std::collections::HashSet::new();
1059    let adj = build_adj(mol);
1060
1061    for atom in &mol.atoms {
1062        let nbrs: Vec<&Atom> = adj[atom.idx].iter().map(|&i| &mol.atoms[i]).collect();
1063        let has_double_O = || {
1064            mol.bonds.iter().any(|b| {
1065                (b.atom_a == atom.idx || b.atom_b == atom.idx) && b.order == BondOrder::Double && {
1066                    let o = if b.atom_a == atom.idx {
1067                        b.atom_b
1068                    } else {
1069                        b.atom_a
1070                    };
1071                    mol.atoms[o].element == "O"
1072                }
1073            })
1074        };
1075        let _has_double_N = || {
1076            mol.bonds.iter().any(|b| {
1077                (b.atom_a == atom.idx || b.atom_b == atom.idx) && b.order == BondOrder::Double && {
1078                    let o = if b.atom_a == atom.idx {
1079                        b.atom_b
1080                    } else {
1081                        b.atom_a
1082                    };
1083                    mol.atoms[o].element == "N"
1084                }
1085            })
1086        };
1087        let has_triple_N = || {
1088            mol.bonds.iter().any(|b| {
1089                (b.atom_a == atom.idx || b.atom_b == atom.idx) && b.order == BondOrder::Triple && {
1090                    let o = if b.atom_a == atom.idx {
1091                        b.atom_b
1092                    } else {
1093                        b.atom_a
1094                    };
1095                    mol.atoms[o].element == "N"
1096                }
1097            })
1098        };
1099
1100        match atom.element.as_str() {
1101            "O" => {
1102                let h = atom.n_implicit_h + atom.explicit_h;
1103                let c_nbr = nbrs.iter().any(|n| n.element == "C");
1104                if h > 0 {
1105                    if c_nbr && !is_carbonyl_oxygen(atom, mol) {
1106                        found.insert(FunctionalGroup::Hydroxyl);
1107                    }
1108                } else if c_nbr && !is_carbonyl_oxygen(atom, mol) {
1109                    found.insert(FunctionalGroup::Ether);
1110                }
1111            }
1112            "N" => {
1113                let h = atom.n_implicit_h + atom.explicit_h;
1114                if atom.is_aromatic {
1115                    found.insert(FunctionalGroup::AromaticAmine);
1116                } else if h >= 2 {
1117                    found.insert(FunctionalGroup::PrimaryAmine);
1118                } else if h == 1 {
1119                    found.insert(FunctionalGroup::SecondaryAmine);
1120                } else {
1121                    found.insert(FunctionalGroup::TertiaryAmine);
1122                }
1123                // Amide N: bonded to a C=O
1124                if is_adjacent_amide(atom, mol) {
1125                    found.insert(FunctionalGroup::Amide);
1126                }
1127                // Sulfonamide: bonded to S
1128                if nbrs.iter().any(|n| n.element == "S") {
1129                    found.insert(FunctionalGroup::Sulfonamide);
1130                }
1131                // Nitro group: non-aromatic N bonded to ≥2 oxygens
1132                if !atom.is_aromatic {
1133                    let o_nbrs: Vec<_> = nbrs.iter().filter(|n| n.element == "O").collect();
1134                    if o_nbrs.len() >= 2 {
1135                        found.insert(FunctionalGroup::Nitro);
1136                    }
1137                }
1138            }
1139            "C" => {
1140                let o_nbrs: Vec<_> = nbrs.iter().filter(|n| n.element == "O").collect();
1141                if has_double_O() {
1142                    let oh_nbr = o_nbrs.iter().any(|o| o.n_implicit_h + o.explicit_h > 0);
1143                    if oh_nbr {
1144                        found.insert(FunctionalGroup::CarboxylicAcid);
1145                    } else {
1146                        let n_nbr = nbrs.iter().any(|n| n.element == "N");
1147                        if n_nbr {
1148                            found.insert(FunctionalGroup::Amide);
1149                        } else {
1150                            let is_terminal = atom.degree == 1;
1151                            if is_terminal || atom.n_implicit_h > 0 {
1152                                found.insert(FunctionalGroup::Aldehyde);
1153                            } else {
1154                                found.insert(FunctionalGroup::Ketone);
1155                            }
1156                        }
1157                    }
1158                    let ether_o = o_nbrs
1159                        .iter()
1160                        .any(|o| o.n_implicit_h == 0 && !is_carbonyl_oxygen(o, mol));
1161                    if ether_o {
1162                        found.insert(FunctionalGroup::Ester);
1163                    }
1164                }
1165                if has_triple_N() {
1166                    found.insert(FunctionalGroup::Nitrile);
1167                }
1168            }
1169            "S" => {
1170                let h = atom.n_implicit_h + atom.explicit_h;
1171                if h > 0 {
1172                    found.insert(FunctionalGroup::Thiol);
1173                } else {
1174                    found.insert(FunctionalGroup::Sulfide);
1175                }
1176            }
1177            "P" => {
1178                found.insert(FunctionalGroup::Phosphate);
1179            }
1180            "F" | "Cl" | "Br" | "I" => {
1181                found.insert(FunctionalGroup::Halide);
1182            }
1183            _ => {}
1184        }
1185        if atom.is_aromatic {
1186            found.insert(FunctionalGroup::AromaticRing);
1187        }
1188    }
1189    let mut result: Vec<FunctionalGroup> = found.into_iter().collect();
1190    result.sort_by_key(|g| format!("{:?}", g));
1191    result
1192}
1193
1194// ─── Chiral centres ───────────────────────────────────────────────────────────
1195
1196/// Count sp3 carbon atoms with 4 distinct substituents (simplified: sp3 C with degree 4).
1197pub fn count_chiral_centers(mol: &Molecule) -> u32 {
1198    let adj = build_adj(mol);
1199    mol.atoms
1200        .iter()
1201        .filter(|a| {
1202            a.element == "C" && !a.is_aromatic && !is_sp2_carbon(a, &adj, mol) && a.degree >= 4
1203        })
1204        .count() as u32
1205}
1206
1207// ─── Morgan circular fingerprint ─────────────────────────────────────────────
1208
1209/// Morgan algorithm: radius-`r` circular fingerprint as sorted Vec<u64> identifiers.
1210pub fn circular_fingerprint(mol: &Molecule, radius: usize) -> Vec<u64> {
1211    let n = mol.atoms.len();
1212    if n == 0 {
1213        return vec![];
1214    }
1215    let adj = build_adj(mol);
1216
1217    // Initial atom invariants (element + charge + degree)
1218    let mut ids: Vec<u64> = mol
1219        .atoms
1220        .iter()
1221        .map(|a| crate::q_hash(&format!("{}{}{}", a.element, a.charge, a.degree)))
1222        .collect();
1223
1224    let mut all_ids = ids.clone();
1225
1226    for _ in 0..radius {
1227        let new_ids: Vec<u64> = (0..n)
1228            .map(|i| {
1229                let mut nbr_ids: Vec<u64> = adj[i].iter().map(|&j| ids[j]).collect();
1230                nbr_ids.sort_unstable();
1231                let combined = format!("{}{:?}", ids[i], nbr_ids);
1232                crate::q_hash(&combined)
1233            })
1234            .collect();
1235        all_ids.extend_from_slice(&new_ids);
1236        ids = new_ids;
1237    }
1238
1239    all_ids.sort_unstable();
1240    all_ids.dedup();
1241    all_ids
1242}
1243
1244// ─── pKa estimation ──────────────────────────────────────────────────────────
1245
1246#[derive(Debug, Clone)]
1247pub struct PkaEstimate {
1248    pub group: FunctionalGroup,
1249    pub pka: f64,
1250    pub is_acid: bool,
1251}
1252
1253/// Estimate pKa values from functional group type (literature reference values).
1254pub fn estimate_pka(mol: &Molecule) -> Vec<PkaEstimate> {
1255    let groups = detect_functional_groups(mol);
1256    let mut estimates = Vec::new();
1257    for group in groups {
1258        let (pka, is_acid) = match &group {
1259            FunctionalGroup::CarboxylicAcid => (4.8, true),
1260            FunctionalGroup::Hydroxyl => (10.0, true),
1261            FunctionalGroup::Thiol => (8.3, true),
1262            FunctionalGroup::Amide => (25.0, true),
1263            FunctionalGroup::Nitrile => (25.0, true),
1264            FunctionalGroup::PrimaryAmine => (10.5, false),
1265            FunctionalGroup::SecondaryAmine => (10.0, false),
1266            FunctionalGroup::TertiaryAmine => (9.5, false),
1267            FunctionalGroup::AromaticAmine => (4.6, false),
1268            FunctionalGroup::Imidazole => (6.0, false),
1269            FunctionalGroup::GuanidiniumLike => (12.5, false),
1270            FunctionalGroup::Phosphate => (2.1, true),
1271            FunctionalGroup::Sulfonamide => (10.0, true),
1272            _ => continue,
1273        };
1274        estimates.push(PkaEstimate {
1275            group,
1276            pka,
1277            is_acid,
1278        });
1279    }
1280    estimates
1281}
1282
1283// ─── SMILES / InChI validation ────────────────────────────────────────────────
1284
1285#[derive(Debug, Clone)]
1286pub struct SmilesValidation {
1287    pub is_valid: bool,
1288    pub atom_count: usize,
1289    pub error: Option<String>,
1290}
1291
1292pub fn validate_smiles(smiles: &str) -> SmilesValidation {
1293    if smiles.trim().is_empty() {
1294        return SmilesValidation {
1295            is_valid: false,
1296            atom_count: 0,
1297            error: Some("Empty SMILES".into()),
1298        };
1299    }
1300    let mol = parse_smiles(smiles);
1301    SmilesValidation {
1302        is_valid: mol.is_valid,
1303        atom_count: mol.atoms.len(),
1304        error: mol.error,
1305    }
1306}
1307
1308#[derive(Debug, Clone)]
1309pub struct InchiValidation {
1310    pub is_valid: bool,
1311    pub has_inchikey: bool,
1312    pub layer_count: usize,
1313}
1314
1315pub fn validate_inchi(inchi: &str) -> InchiValidation {
1316    // Standard InChI: starts with "InChI=1S/" followed by formula and layers
1317    // InChIKey: 27-character hash XXXXXXXXXXXXXX-XXXXXXXXXX-N
1318    let is_inchi = inchi.starts_with("InChI=1S/") || inchi.starts_with("InChI=1/");
1319    let is_inchikey = inchi.len() == 27
1320        && inchi.chars().nth(14) == Some('-')
1321        && inchi.chars().nth(25) == Some('-');
1322    let layers = if is_inchi {
1323        inchi.split('/').count().saturating_sub(2)
1324    } else {
1325        0
1326    };
1327    InchiValidation {
1328        is_valid: is_inchi || is_inchikey,
1329        has_inchikey: is_inchikey,
1330        layer_count: layers,
1331    }
1332}
1333
1334// ─── Thermochemistry ─────────────────────────────────────────────────────────
1335
1336/// Arrhenius rate constant k at temperature T (K).
1337/// k = A × exp(−Ea / (R × T))
1338/// `activation_energy_j_mol`: Ea in J/mol
1339pub fn arrhenius_rate(pre_exponential_a: f64, activation_energy_j_mol: f64, temp_k: f64) -> f64 {
1340    pre_exponential_a * f64::exp(-activation_energy_j_mol / (R_J_MOL_K * temp_k))
1341}
1342
1343/// Temperature dependence of k using the Arrhenius equation.
1344/// Returns (k_t1, k_t2).
1345pub fn arrhenius_ratio(ea_j_mol: f64, t1_k: f64, t2_k: f64) -> f64 {
1346    f64::exp((ea_j_mol / R_J_MOL_K) * (1.0 / t1_k - 1.0 / t2_k))
1347}
1348
1349/// Gibbs free energy ΔG = ΔH − T × ΔS  (all in J/mol or kJ/mol consistently).
1350pub fn gibbs_free_energy(delta_h: f64, delta_s: f64, temp_k: f64) -> f64 {
1351    delta_h - temp_k * delta_s
1352}
1353
1354/// Equilibrium constant from ΔG°: K = exp(−ΔG° / (R × T))
1355pub fn equilibrium_constant(delta_g_j_mol: f64, temp_k: f64) -> f64 {
1356    f64::exp(-delta_g_j_mol / (R_J_MOL_K * temp_k))
1357}
1358
1359/// ΔG° from equilibrium constant K: ΔG° = −R × T × ln(K)
1360pub fn gibbs_from_equilibrium(k_eq: f64, temp_k: f64) -> f64 {
1361    -R_J_MOL_K * temp_k * k_eq.ln()
1362}
1363
1364/// Gibbs–Helmholtz: ΔG(T2) from ΔG(T1).
1365pub fn gibbs_helmholtz(delta_g_t1: f64, delta_h: f64, t1_k: f64, t2_k: f64) -> f64 {
1366    t2_k * (delta_g_t1 / t1_k + delta_h * (1.0 / t1_k - 1.0 / t2_k))
1367}
1368
1369/// van't Hoff enthalpy estimate from two equilibrium constants at two temperatures.
1370pub fn vant_hoff_enthalpy(k1: f64, k2: f64, t1_k: f64, t2_k: f64) -> f64 {
1371    -R_J_MOL_K * (k2 / k1).ln() / (1.0 / t2_k - 1.0 / t1_k)
1372}
1373
1374/// Henderson–Hasselbalch: pH = pKa + log10([A-] / [HA])
1375pub fn henderson_hasselbalch(pka: f64, conc_base: f64, conc_acid: f64) -> f64 {
1376    if conc_acid <= 0.0 || conc_base < 0.0 {
1377        return pka;
1378    }
1379    pka + (conc_base / conc_acid).log10()
1380}
1381
1382// ─── Decentralized synthesis: urea pathway, catalyst decay, off-grid kinetics ────
1383//
1384// The resilience-chemistry scope: domestic production of critical compounds at the
1385// edge. These build on the Arrhenius / equilibrium primitives above. Honest scope —
1386// the engine carries the reaction arithmetic; the attested thermodynamic data
1387// (ΔG°, Ea, A) is supplied by the caller, not invented here.
1388
1389/// Equilibrium extent ξ of the Bosch–Meiser urea synthesis
1390/// `2 NH₃ + CO₂ ⇌ (NH₂)₂CO + H₂O`, solved from the equilibrium constant (via the
1391/// caller-supplied ΔG° at `temp_k`) and the initial NH₃/CO₂ partial pressures, by
1392/// bisection on the reaction quotient `Q(ξ) = ξ² / ((p_NH₃−2ξ)²·(p_CO₂−ξ))` (strictly
1393/// increasing in ξ). Returns ξ in the same units as the input pressures. Simplified
1394/// gas-phase model (industrial urea is liquid-phase/high-P — that shifts the numbers,
1395/// not the method). Demonstrates Le Chatelier: higher reactant pressure ⇒ higher ξ.
1396pub fn urea_equilibrium_extent(delta_g_j_mol: f64, temp_k: f64, p_nh3_0: f64, p_co2_0: f64) -> f64 {
1397    let k_eq = equilibrium_constant(delta_g_j_mol, temp_k);
1398    let xi_max = (p_nh3_0 / 2.0).min(p_co2_0).max(0.0);
1399    if xi_max <= 0.0 {
1400        return 0.0;
1401    }
1402    let quotient = |xi: f64| -> f64 {
1403        let nh3 = p_nh3_0 - 2.0 * xi;
1404        let co2 = p_co2_0 - xi;
1405        if nh3 <= 0.0 || co2 <= 0.0 {
1406            return f64::INFINITY;
1407        }
1408        (xi * xi) / (nh3 * nh3 * co2)
1409    };
1410    let (mut lo, mut hi) = (0.0f64, xi_max * (1.0 - 1e-9));
1411    for _ in 0..100 {
1412        let mid = 0.5 * (lo + hi);
1413        if quotient(mid) < k_eq {
1414            lo = mid;
1415        } else {
1416            hi = mid;
1417        }
1418    }
1419    0.5 * (lo + hi)
1420}
1421
1422/// Catalyst activity at time `t` under first-order deactivation: `a(t) = a₀·exp(−k_d·t)`.
1423/// Models the degradation that limits sustained yield on small decentralized nodes.
1424pub fn catalyst_activity(initial_activity: f64, deactivation_rate_per_s: f64, time_s: f64) -> f64 {
1425    let a = initial_activity * (-deactivation_rate_per_s * time_s).exp();
1426    a.clamp(0.0, initial_activity.max(0.0))
1427}
1428
1429/// Effective reaction rate accounting for catalyst decay: `r_eff = r_base · a(t)/a₀`.
1430pub fn deactivated_reaction_rate(
1431    base_rate: f64,
1432    initial_activity: f64,
1433    deactivation_rate_per_s: f64,
1434    time_s: f64,
1435) -> f64 {
1436    if initial_activity <= 0.0 {
1437        return 0.0;
1438    }
1439    base_rate * catalyst_activity(initial_activity, deactivation_rate_per_s, time_s)
1440        / initial_activity
1441}
1442
1443/// Total fractional conversion of a first-order reaction under a *variable* temperature
1444/// profile — e.g. fluctuating off-grid power driving a fluctuating reactor temperature.
1445/// Integrates `dX/dt = k(T)·(1−X)` across `temp_profile_k` (k from the Arrhenius rate,
1446/// explicit-Euler steps of `dt_s`), returning the final conversion `X ∈ [0,1]`. Zero-heap
1447/// (the profile is a caller slice).
1448pub fn conversion_under_variable_temperature(
1449    pre_exponential_a: f64,
1450    activation_energy_j_mol: f64,
1451    temp_profile_k: &[f64],
1452    dt_s: f64,
1453) -> f64 {
1454    let mut x = 0.0f64;
1455    for &t_k in temp_profile_k {
1456        let k = arrhenius_rate(pre_exponential_a, activation_energy_j_mol, t_k);
1457        x += k * (1.0 - x) * dt_s;
1458        x = x.clamp(0.0, 1.0);
1459    }
1460    x
1461}
1462
1463#[cfg(test)]
1464mod resilience_chem_tests {
1465    use super::*;
1466
1467    #[test]
1468    fn urea_extent_tracks_equilibrium_favorability() {
1469        // Product-favoured (ΔG° = −10 kJ/mol at 400 K → K ≈ 20): with p_NH3=2, p_CO2=1
1470        // the equilibrium extent ξ ≈ 0.8 (Q(0.8)=0.64/(0.16·0.2)=20).
1471        let xi = urea_equilibrium_extent(-10_000.0, 400.0, 2.0, 1.0);
1472        assert!(xi > 0.7 && xi < 0.9, "favourable extent ~0.8, got {xi}");
1473        // Strongly unfavourable (ΔG° = +60 kJ/mol → K ≈ 0): almost no conversion.
1474        let xi_bad = urea_equilibrium_extent(60_000.0, 400.0, 2.0, 1.0);
1475        assert!(xi_bad < 0.01, "unfavourable extent ~0, got {xi_bad}");
1476        // Le Chatelier: more reactant pressure ⇒ more product.
1477        let xi_lo = urea_equilibrium_extent(-10_000.0, 400.0, 1.0, 0.5);
1478        let xi_hi = urea_equilibrium_extent(-10_000.0, 400.0, 4.0, 2.0);
1479        assert!(
1480            xi_hi > xi_lo,
1481            "higher reactant pressure should raise the extent"
1482        );
1483    }
1484
1485    #[test]
1486    fn catalyst_decays_and_throttles_rate() {
1487        let a0 = 1.0;
1488        assert!((catalyst_activity(a0, 0.1, 0.0) - a0).abs() < 1e-12);
1489        let later = catalyst_activity(a0, 0.1, 10.0);
1490        assert!(later < a0 && later > 0.0);
1491        // r_eff = base · e^{-1} ≈ 0.368 · base after k_d·t = 1.
1492        let r = deactivated_reaction_rate(10.0, a0, 0.1, 10.0);
1493        assert!((r - 10.0 * (-1.0f64).exp()).abs() < 1e-9);
1494    }
1495
1496    #[test]
1497    fn hotter_profile_gives_more_conversion() {
1498        let (a, ea, dt) = (1e6, 50_000.0, 1.0);
1499        let cold = conversion_under_variable_temperature(a, ea, &[300.0, 300.0, 300.0], dt);
1500        let hot = conversion_under_variable_temperature(a, ea, &[350.0, 350.0, 350.0], dt);
1501        assert!(
1502            hot > cold,
1503            "higher temperature ⇒ faster kinetics ⇒ more conversion"
1504        );
1505        assert!((0.0..=1.0).contains(&hot) && (0.0..=1.0).contains(&cold));
1506    }
1507}
1508
1509/// Degree of ionisation α at a given pH for a monoprotic acid.
1510pub fn ionisation_fraction(ph: f64, pka: f64) -> f64 {
1511    1.0 / (1.0 + 10f64.powf(pka - ph))
1512}
1513
1514// ─── Green chemistry metrics ─────────────────────────────────────────────────
1515
1516#[derive(Debug, Clone)]
1517pub struct GreenMetrics {
1518    /// Trost 1991: desired product MW / sum of all reactant MWs × 100 %
1519    pub atom_economy_pct: f64,
1520    /// Yield-corrected AE (YAAE)
1521    pub yield_corrected_ae_pct: f64,
1522    /// Sheldon 1992: kg waste / kg desired product
1523    pub e_factor: f64,
1524    /// Mass-based E-factor considering all inputs including solvents
1525    pub process_mass_intensity: f64,
1526    /// Andraos 2005: kg product / kg total reactants × 100 %
1527    pub reaction_mass_efficiency_pct: f64,
1528    /// Carbon efficiency: C atoms in product / C atoms in reactants × 100 %
1529    pub carbon_efficiency_pct: f64,
1530}
1531
1532pub fn green_metrics(
1533    reactant_mws: &[f64],
1534    product_mw: f64,
1535    byproduct_mws: &[f64],
1536    yield_fraction: f64, // 0.0–1.0
1537    solvent_and_auxiliary_kg: f64,
1538    product_kg: f64,
1539    reactant_c_atoms: u32,
1540    product_c_atoms: u32,
1541) -> GreenMetrics {
1542    let sum_reactants: f64 = reactant_mws.iter().sum();
1543    let ae = if sum_reactants > 0.0 {
1544        100.0 * product_mw / sum_reactants
1545    } else {
1546        0.0
1547    };
1548    let yaae = ae * yield_fraction;
1549
1550    let waste_kg = reactant_mws.iter().sum::<f64>()
1551        + byproduct_mws.iter().sum::<f64>()
1552        + solvent_and_auxiliary_kg
1553        - product_kg;
1554    let ef = if product_kg > 0.0 {
1555        waste_kg.max(0.0) / product_kg
1556    } else {
1557        f64::INFINITY
1558    };
1559
1560    let total_in = reactant_mws.iter().sum::<f64>() + solvent_and_auxiliary_kg;
1561    let pmi = if product_kg > 0.0 {
1562        total_in / product_kg
1563    } else {
1564        f64::INFINITY
1565    };
1566
1567    let effective_product_kg = product_kg * yield_fraction.max(0.0);
1568    let rme = if sum_reactants > 0.0 {
1569        100.0 * effective_product_kg / sum_reactants
1570    } else {
1571        0.0
1572    };
1573
1574    let ce = if reactant_c_atoms > 0 {
1575        100.0 * product_c_atoms as f64 / reactant_c_atoms as f64
1576    } else {
1577        0.0
1578    };
1579
1580    GreenMetrics {
1581        atom_economy_pct: ae,
1582        yield_corrected_ae_pct: yaae,
1583        e_factor: ef,
1584        process_mass_intensity: pmi,
1585        reaction_mass_efficiency_pct: rme,
1586        carbon_efficiency_pct: ce,
1587    }
1588}
1589
1590/// Simplified atom economy (Trost): single product vs all reactants.
1591pub fn atom_economy(reactant_mws: &[f64], desired_product_mw: f64) -> f64 {
1592    let sum: f64 = reactant_mws.iter().sum();
1593    if sum == 0.0 {
1594        0.0
1595    } else {
1596        100.0 * desired_product_mw / sum
1597    }
1598}
1599
1600/// E-factor (Sheldon): waste_kg / product_kg.  Fine chemicals < 50; bulk < 5; pharma 25–100.
1601pub fn e_factor(waste_kg: f64, product_kg: f64) -> f64 {
1602    if product_kg <= 0.0 {
1603        f64::INFINITY
1604    } else {
1605        waste_kg / product_kg
1606    }
1607}
1608
1609// ─── ADMET & Lead Optimization Metrics ───────────────────────────────────────
1610
1611#[derive(Debug, Clone, Copy)]
1612pub struct BbbPermeationResult {
1613    pub clark_score: u8,        // MPO score 0-4
1614    pub is_cns_penetrant: bool, // MPO >= 3 is typically considered penetrant
1615}
1616
1617/// Evaluates Blood-Brain Barrier (BBB) permeation probability using Clark's
1618/// rules (Molecular Weight, LogP, PSA, HBD).
1619pub fn predict_bbb_permeation(mw: f64, log_p: f64, psa: f64, hbd: u32) -> BbbPermeationResult {
1620    let mut clark_score = 0;
1621    if mw <= 400.0 {
1622        clark_score += 1;
1623    }
1624    if log_p >= 2.0 && log_p <= 5.0 {
1625        clark_score += 1;
1626    }
1627    if psa <= 90.0 {
1628        clark_score += 1;
1629    }
1630    if hbd <= 3 {
1631        clark_score += 1;
1632    }
1633
1634    BbbPermeationResult {
1635        clark_score,
1636        is_cns_penetrant: clark_score >= 3,
1637    }
1638}
1639
1640/// Computes Ligand Efficiency (LE): pIC50 / Heavy Atom Count (HAC).
1641/// Standard target is LE >= 0.3.
1642pub fn ligand_efficiency(pic50: f64, hac: u32) -> f64 {
1643    if hac == 0 {
1644        0.0
1645    } else {
1646        (pic50 * 1.37) / (hac as f64)
1647    }
1648}
1649
1650/// Computes Lipophilic Ligand Efficiency (LLE): pIC50 - LogP.
1651/// Standard target is LLE >= 5.0.
1652pub fn lipophilic_ligand_efficiency(pic50: f64, log_p: f64) -> f64 {
1653    pic50 - log_p
1654}
1655
1656// ─── Mass Spectrometry Simulation ────────────────────────────────────────────
1657
1658#[derive(Debug, Clone, Copy)]
1659pub struct IsotopeDistribution {
1660    pub m_peak: f64,  // 100% (normalized)
1661    pub m1_peak: f64, // M+1 relative intensity
1662    pub m2_peak: f64, // M+2 relative intensity
1663}
1664
1665/// Computes the theoretical M, M+1, M+2 isotopic distribution based on the number
1666/// of Carbon, Nitrogen, Oxygen, Sulfur, Chlorine, and Bromine atoms.
1667pub fn isotope_mass_distribution(
1668    c: u32,
1669    n: u32,
1670    o: u32,
1671    s: u32,
1672    cl: u32,
1673    br: u32,
1674) -> IsotopeDistribution {
1675    // Relative abundance approximations (natural)
1676    let c13 = 0.0107;
1677    let n15 = 0.0036;
1678    let o18 = 0.0020;
1679    let s33 = 0.0076;
1680    let s34 = 0.0429;
1681    let cl37 = 0.3197;
1682    let br81 = 0.9728; // Br is ~50.69% 79Br, ~49.31% 81Br (ratio ~ 0.97)
1683
1684    // M+1 contributions
1685    let m1 = (c as f64) * c13 + (n as f64) * n15 + (s as f64) * s33;
1686
1687    // M+2 contributions
1688    let m2 = ((c as f64) * c13).powi(2) / 2.0
1689        + (o as f64) * o18
1690        + (s as f64) * s34
1691        + (cl as f64) * cl37
1692        + (br as f64) * br81;
1693
1694    IsotopeDistribution {
1695        m_peak: 100.0,
1696        m1_peak: m1 * 100.0,
1697        m2_peak: m2 * 100.0,
1698    }
1699}
1700
1701// ─── Tests ────────────────────────────────────────────────────────────────────
1702
1703#[cfg(test)]
1704mod tests {
1705    use super::*;
1706
1707    fn aspirin() -> Molecule {
1708        parse_smiles("CC(=O)Oc1ccccc1C(=O)O")
1709    }
1710    fn paracetamol() -> Molecule {
1711        parse_smiles("CC(=O)Nc1ccc(O)cc1")
1712    }
1713    fn ethanol() -> Molecule {
1714        parse_smiles("CCO")
1715    }
1716    fn caffeine() -> Molecule {
1717        parse_smiles("Cn1cnc2c1c(=O)n(c(=O)n2C)C")
1718    }
1719    fn methane() -> Molecule {
1720        parse_smiles("C")
1721    }
1722
1723    #[test]
1724    fn smiles_parse_ethanol_atoms() {
1725        let mol = ethanol();
1726        assert!(mol.is_valid);
1727        assert_eq!(mol.atoms.iter().filter(|a| a.element == "C").count(), 2);
1728        assert_eq!(mol.atoms.iter().filter(|a| a.element == "O").count(), 1);
1729    }
1730
1731    #[test]
1732    fn smiles_parse_paracetamol_methane() {
1733        let p = paracetamol();
1734        assert!(p.is_valid);
1735        assert_eq!(p.atoms.iter().filter(|a| a.element == "C").count(), 8);
1736        let m = methane();
1737        assert!(m.is_valid);
1738        assert_eq!(m.atoms.iter().filter(|a| a.element == "C").count(), 1);
1739    }
1740
1741    #[test]
1742    fn molecular_weight_ethanol() {
1743        let mol = ethanol();
1744        let mw = exact_molecular_weight(&mol);
1745        assert!(
1746            (mw - 46.068).abs() < 1.0,
1747            "ethanol MW ~ 46.07, got {:.3}",
1748            mw
1749        );
1750    }
1751
1752    #[test]
1753    fn molecular_weight_aspirin() {
1754        let mol = aspirin();
1755        let mw = exact_molecular_weight(&mol);
1756        assert!((mw - 180.0).abs() < 5.0, "aspirin MW ~ 180, got {:.2}", mw);
1757    }
1758
1759    #[test]
1760    fn lipinski_aspirin_passes() {
1761        let desc = compute_descriptors(&aspirin());
1762        let r = evaluate_lipinski(&desc);
1763        assert!(r.passes, "Aspirin should pass Lipinski");
1764    }
1765
1766    #[test]
1767    fn lipinski_caffeine_passes() {
1768        let desc = compute_descriptors(&caffeine());
1769        let r = evaluate_lipinski(&desc);
1770        assert!(r.passes, "Caffeine should pass Lipinski");
1771    }
1772
1773    #[test]
1774    fn caffeine_has_aromatic_ring_descriptor() {
1775        let desc = compute_descriptors(&caffeine());
1776        assert!(
1777            desc.aromatic_ring_count > 0,
1778            "caffeine should report at least one aromatic ring"
1779        );
1780    }
1781
1782    #[test]
1783    fn functional_groups_ethanol() {
1784        let groups = detect_functional_groups(&ethanol());
1785        assert!(
1786            groups.contains(&FunctionalGroup::Hydroxyl),
1787            "ethanol should have hydroxyl"
1788        );
1789    }
1790
1791    #[test]
1792    fn functional_groups_aspirin() {
1793        let groups = detect_functional_groups(&aspirin());
1794        assert!(
1795            groups.contains(&FunctionalGroup::CarboxylicAcid)
1796                || groups.contains(&FunctionalGroup::Ester)
1797                || groups.contains(&FunctionalGroup::AromaticRing)
1798        );
1799    }
1800
1801    #[test]
1802    fn green_metrics_rme_tracks_yield() {
1803        let reactants = [46.068];
1804        let product_mw = 46.068;
1805        let high = green_metrics(&reactants, product_mw, &[], 0.99, 5.0, 1.0, 0, 0);
1806        let low = green_metrics(&reactants, product_mw, &[], 0.10, 5.0, 1.0, 0, 0);
1807        assert!(high.reaction_mass_efficiency_pct > low.reaction_mass_efficiency_pct);
1808    }
1809
1810    #[test]
1811    fn tpsa_ethanol_reasonable() {
1812        let mol = ethanol();
1813        let tpsa = compute_tpsa(&mol);
1814        assert!(
1815            tpsa > 10.0 && tpsa < 40.0,
1816            "ethanol TPSA ~ 20 Ų, got {:.1}",
1817            tpsa
1818        );
1819    }
1820
1821    #[test]
1822    fn test_bbb_permeation() {
1823        // Example: Diazepam (MW 284.7, LogP ~2.8, PSA ~32.6, HBD 0)
1824        let r = predict_bbb_permeation(284.7, 2.8, 32.6, 0);
1825        assert_eq!(r.clark_score, 4);
1826        assert!(r.is_cns_penetrant);
1827    }
1828
1829    #[test]
1830    fn test_ligand_efficiency() {
1831        let le = ligand_efficiency(8.0, 25);
1832        assert!((le - 0.4384).abs() < 0.01);
1833        let lle = lipophilic_ligand_efficiency(8.0, 3.0);
1834        assert!((lle - 5.0).abs() < 0.01);
1835    }
1836
1837    #[test]
1838    fn test_isotope_distribution() {
1839        // Bromobenzene: C6 H5 Br1
1840        // M+2 from Br: 1 × br81_ratio = 0.9728 → 97.28%
1841        // M+2 from C6:  (6 × 0.0107)² / 2 = 0.00206 → 0.206%
1842        // Combined M+2 ≈ 97.49%
1843        let r = isotope_mass_distribution(6, 0, 0, 0, 0, 1);
1844        assert!((r.m_peak - 100.0).abs() < 0.1);
1845        assert!((r.m1_peak - 6.42).abs() < 0.1);
1846        assert!(
1847            (r.m2_peak - 97.49).abs() < 0.5,
1848            "M+2 for bromobenzene expected ~97.49%, got {}",
1849            r.m2_peak
1850        );
1851    }
1852
1853    #[test]
1854    fn arrhenius_rate_increases_with_temp() {
1855        let k25 = arrhenius_rate(1e13, 80_000.0, 298.0);
1856        let k100 = arrhenius_rate(1e13, 80_000.0, 373.0);
1857        assert!(k100 > k25, "rate should increase with temperature");
1858    }
1859
1860    #[test]
1861    fn gibbs_standard_conditions() {
1862        // ΔG = ΔH - TΔS: endothermic, low ΔS should give positive ΔG at 298K
1863        let dg = gibbs_free_energy(10_000.0, 20.0, 298.0);
1864        assert!(dg < 10_000.0);
1865    }
1866
1867    #[test]
1868    fn equilibrium_constant_roundtrip() {
1869        let delta_g = -5_000.0; // negative ΔG → K > 1
1870        let k = equilibrium_constant(delta_g, 298.0);
1871        let dg_back = gibbs_from_equilibrium(k, 298.0);
1872        assert!((dg_back - delta_g).abs() < 1.0, "roundtrip ΔG");
1873    }
1874
1875    #[test]
1876    fn henderson_hasselbalch_half_ionised() {
1877        // pH = pKa when [A-] = [HA]
1878        let ph = henderson_hasselbalch(4.8, 1.0, 1.0);
1879        assert!((ph - 4.8).abs() < 1e-9);
1880    }
1881
1882    #[test]
1883    fn atom_economy_100pct_addition() {
1884        // A + B → AB (100% AE)
1885        let ae = atom_economy(&[100.0, 80.0], 180.0);
1886        assert!((ae - 100.0).abs() < 1e-9);
1887    }
1888
1889    #[test]
1890    fn e_factor_zero_waste() {
1891        assert_eq!(e_factor(0.0, 1.0), 0.0);
1892    }
1893
1894    #[test]
1895    fn validate_smiles_valid() {
1896        let r = validate_smiles("CCO");
1897        assert!(r.is_valid);
1898    }
1899
1900    #[test]
1901    fn validate_smiles_empty() {
1902        let r = validate_smiles("");
1903        assert!(!r.is_valid);
1904    }
1905
1906    #[test]
1907    fn validate_inchi_standard() {
1908        let r = validate_inchi("InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3");
1909        assert!(r.is_valid);
1910        assert!(!r.has_inchikey);
1911    }
1912
1913    #[test]
1914    fn validate_inchikey_format() {
1915        let r = validate_inchi("LFQSCWFLJHTTHZ-UHFFFAOYSA-N");
1916        assert!(r.is_valid);
1917        assert!(r.has_inchikey);
1918    }
1919
1920    #[test]
1921    fn circular_fingerprint_different_for_different_molecules() {
1922        let fp1 = circular_fingerprint(&ethanol(), 2);
1923        let fp2 = circular_fingerprint(&aspirin(), 2);
1924        assert_ne!(fp1, fp2);
1925    }
1926
1927    #[test]
1928    fn pka_carboxylic_acid() {
1929        let mol = parse_smiles("CC(=O)O"); // acetic acid
1930        let pkas = estimate_pka(&mol);
1931        assert!(pkas
1932            .iter()
1933            .any(|p| p.group == FunctionalGroup::CarboxylicAcid));
1934    }
1935
1936    #[test]
1937    fn veber_passes_small_molecule() {
1938        let mol = ethanol();
1939        let desc = compute_descriptors(&mol);
1940        let r = evaluate_veber(&desc);
1941        assert!(r.passes);
1942    }
1943}