1pub const R_J_MOL_K: f64 = 8.314_462_618;
45
46const 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) }
74
75const 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#[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 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 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
144pub 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 '(' => {
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 '-' => {
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 '.' => {
231 current_atom = None;
232 pending_bond = None;
233 pos += 1;
234 }
235 '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 '%' 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 '[' => {
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 '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 '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 if !ring_map.is_empty() {
344 error = Some(format!(
345 "Unclosed ring bonds: {:?}",
346 ring_map.keys().collect::<Vec<_>>()
347 ));
348 }
349
350 fill_implicit_hydrogens(&mut atoms, &bonds);
352 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
394fn parse_bracket_atom(
396 chars: &[char],
397 start: usize,
398) -> (&'static str, bool, i8, Option<u16>, u8, usize) {
399 let mut pos = start + 1; let end = chars.len();
401
402 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 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 let is_aromatic = sym_start < chars.len() && chars[sym_start].is_ascii_lowercase();
423
424 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 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; } else {
448 charge = sign;
449 }
450 }
451
452 while pos < end && chars[pos] != ']' {
454 pos += 1;
455 }
456 let advance = pos - start + 1; 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 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 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
516fn mark_ring_bonds(atoms: &[Atom], bonds: &mut Vec<Bond>) {
518 let n = atoms.len();
522 let mut adj: Vec<Vec<(usize, usize)>> = vec![vec![]; n]; 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
555pub 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
570pub 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
591pub 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#[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
618pub 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 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 let hba = mol
633 .atoms
634 .iter()
635 .filter(|a| a.element == "N" || a.element == "O")
636 .count() as u32;
637
638 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 let aro_ring = count_aromatic_rings(&mol.atoms, &mol.bonds);
655 let ring_ct = count_all_rings(&mol.bonds);
656
657 let logp = compute_logp(mol);
659
660 let tpsa = compute_tpsa(mol);
662
663 let chiral = count_chiral_centers(mol);
665
666 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 let ring_bonds = bonds
714 .iter()
715 .filter(|b| b.in_ring && b.order == BondOrder::Aromatic)
716 .count();
717 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 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 }
736
737pub 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 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
866pub 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 else if total_h >= 1.0 {
884 16.61
885 }
886 else if is_adjacent_amide(atom, mol) {
888 29.42
889 } else {
890 11.49
891 } }
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 else if is_carbonyl_oxygen(atom, mol) {
903 17.07
904 } else {
905 9.23
906 } }
908 "S" => {
909 if atom.is_aromatic {
910 0.0
911 } else if total_h >= 1.0 {
912 38.80
913 }
914 else {
916 32.09
917 } }
919 "P" => 34.14,
920 _ => 0.0,
921 };
922 tpsa += contrib;
923 }
924 tpsa
925}
926
927#[derive(Debug, Clone)]
930pub struct LipinskiResult {
931 pub mw_ok: bool,
933 pub logp_ok: bool,
935 pub hbd_ok: bool,
937 pub hba_ok: bool,
939 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 pub rot_bonds_ok: bool,
964 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 pub mw_ok: bool,
983 pub logp_ok: bool,
985 pub atoms_ok: bool,
987 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 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 pub tpsa_ok: bool,
1013 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1031pub enum FunctionalGroup {
1032 Hydroxyl, PrimaryAmine, SecondaryAmine, TertiaryAmine, AromaticAmine, Amide, CarboxylicAcid, Ester, Ketone, Aldehyde, Ether, Thiol, Sulfide, Sulfonamide, Halide, AromaticRing,
1048 Nitrile, Nitro, Phosphate, Imidazole,
1052 GuanidiniumLike,
1053}
1054
1055#[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 if is_adjacent_amide(atom, mol) {
1125 found.insert(FunctionalGroup::Amide);
1126 }
1127 if nbrs.iter().any(|n| n.element == "S") {
1129 found.insert(FunctionalGroup::Sulfonamide);
1130 }
1131 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
1194pub 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
1207pub 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 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#[derive(Debug, Clone)]
1247pub struct PkaEstimate {
1248 pub group: FunctionalGroup,
1249 pub pka: f64,
1250 pub is_acid: bool,
1251}
1252
1253pub 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#[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 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
1334pub 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
1343pub 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
1349pub fn gibbs_free_energy(delta_h: f64, delta_s: f64, temp_k: f64) -> f64 {
1351 delta_h - temp_k * delta_s
1352}
1353
1354pub 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
1359pub fn gibbs_from_equilibrium(k_eq: f64, temp_k: f64) -> f64 {
1361 -R_J_MOL_K * temp_k * k_eq.ln()
1362}
1363
1364pub 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
1369pub 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
1374pub 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
1382pub 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
1422pub 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
1429pub 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
1443pub 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 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 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 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 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
1509pub fn ionisation_fraction(ph: f64, pka: f64) -> f64 {
1511 1.0 / (1.0 + 10f64.powf(pka - ph))
1512}
1513
1514#[derive(Debug, Clone)]
1517pub struct GreenMetrics {
1518 pub atom_economy_pct: f64,
1520 pub yield_corrected_ae_pct: f64,
1522 pub e_factor: f64,
1524 pub process_mass_intensity: f64,
1526 pub reaction_mass_efficiency_pct: f64,
1528 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, 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
1590pub 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
1600pub 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#[derive(Debug, Clone, Copy)]
1612pub struct BbbPermeationResult {
1613 pub clark_score: u8, pub is_cns_penetrant: bool, }
1616
1617pub 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
1640pub 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
1650pub fn lipophilic_ligand_efficiency(pic50: f64, log_p: f64) -> f64 {
1653 pic50 - log_p
1654}
1655
1656#[derive(Debug, Clone, Copy)]
1659pub struct IsotopeDistribution {
1660 pub m_peak: f64, pub m1_peak: f64, pub m2_peak: f64, }
1664
1665pub 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 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; let m1 = (c as f64) * c13 + (n as f64) * n15 + (s as f64) * s33;
1686
1687 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#[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(ðanol());
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 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 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 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; 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 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 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(ðanol(), 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"); 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}