1use crate::db;
8use crate::model::{
9 atom::Atom,
10 grid::Grid,
11 residue::Residue,
12 structure::Structure,
13 types::{Element, Point, ResidueCategory, ResiduePosition, StandardResidue},
14};
15use crate::ops::error::Error;
16use crate::utils::parallel::*;
17use nalgebra::{Matrix3, Rotation3, Vector3};
18use rand::Rng;
19use std::collections::HashSet;
20
21const HIS_HIP_PKA: f64 = 6.0;
23const ASP_PKA: f64 = 3.9;
25const GLU_PKA: f64 = 4.2;
27const LYS_PKA: f64 = 10.5;
29const ARG_PKA: f64 = 12.5;
31const CYS_PKA: f64 = 8.3;
33const TYR_PKA: f64 = 10.0;
35const N_TERM_PKA: f64 = 8.0;
37const C_TERM_PKA: f64 = 3.1;
39const PHOSPHATE_PKA2: f64 = 6.5;
41const DEFAULT_TERMINAL_PH: f64 = 7.0;
43const DISULFIDE_SG_THRESHOLD: f64 = 2.2;
45const SALT_BRIDGE_DISTANCE: f64 = 4.0;
47const SP3_ANGLE: f64 = 109.5;
49const NH_BOND_LENGTH: f64 = 1.01;
51const OH_BOND_LENGTH: f64 = 0.96;
53const COOH_BOND_LENGTH: f64 = 0.97;
55
56#[derive(Debug, Clone)]
61pub struct HydroConfig {
62 pub target_ph: Option<f64>,
64 pub remove_existing_h: bool,
66 pub his_strategy: HisStrategy,
68 pub his_salt_bridge_protonation: bool,
71}
72
73impl Default for HydroConfig {
74 fn default() -> Self {
78 Self {
79 target_ph: None,
80 remove_existing_h: true,
81 his_strategy: HisStrategy::HbNetwork,
82 his_salt_bridge_protonation: true,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum HisStrategy {
90 DirectHID,
92 DirectHIE,
94 Random,
96 HbNetwork,
99}
100
101pub fn add_hydrogens(structure: &mut Structure, config: &HydroConfig) -> Result<(), Error> {
127 mark_disulfide_bridges(structure);
128
129 let acceptor_grid = if config.his_strategy == HisStrategy::HbNetwork
130 && config.target_ph.is_some_and(|ph| ph >= HIS_HIP_PKA)
131 {
132 Some(build_acceptor_grid(structure))
133 } else {
134 None
135 };
136
137 if config.target_ph.is_some() {
138 apply_non_his_protonation(structure, config.target_ph.unwrap());
139 }
140
141 let carboxylate_grid = if config.his_salt_bridge_protonation {
142 Some(build_carboxylate_grid(structure, config.target_ph))
143 } else {
144 None
145 };
146
147 structure
148 .par_chains_mut()
149 .enumerate()
150 .try_for_each(|(c_idx, chain)| {
151 chain
152 .par_residues_mut()
153 .enumerate()
154 .try_for_each(|(r_idx, residue)| {
155 if residue.category != ResidueCategory::Standard {
156 return Ok(());
157 }
158
159 if let Some(StandardResidue::HIS) = residue.standard_name
160 && let Some(new_name) = determine_his_protonation(
161 residue,
162 config,
163 acceptor_grid.as_ref(),
164 carboxylate_grid.as_ref(),
165 (c_idx, r_idx),
166 )
167 {
168 residue.name = new_name.into();
169 }
170
171 if config.remove_existing_h {
172 residue.strip_hydrogens();
173 }
174
175 construct_hydrogens_for_residue(residue, config)
176 })
177 })
178}
179
180fn apply_non_his_protonation(structure: &mut Structure, ph: f64) {
189 structure.par_residues_mut().for_each(|residue| {
190 if residue.category != ResidueCategory::Standard {
191 return;
192 }
193
194 if residue.name == "CYX" {
195 return;
196 }
197
198 let new_name = match residue.standard_name {
199 Some(StandardResidue::ASP) => Some(if ph < ASP_PKA { "ASH" } else { "ASP" }),
200 Some(StandardResidue::GLU) => Some(if ph < GLU_PKA { "GLH" } else { "GLU" }),
201 Some(StandardResidue::LYS) => Some(if ph > LYS_PKA { "LYN" } else { "LYS" }),
202 Some(StandardResidue::ARG) => Some(if ph > ARG_PKA { "ARN" } else { "ARG" }),
203 Some(StandardResidue::CYS) => Some(if ph > CYS_PKA { "CYM" } else { "CYS" }),
204 Some(StandardResidue::TYR) => Some(if ph > TYR_PKA { "TYM" } else { "TYR" }),
205 _ => None,
206 };
207
208 if let Some(name) = new_name {
209 residue.name = name.into();
210 }
211 });
212}
213
214fn build_carboxylate_grid(structure: &Structure, target_ph: Option<f64>) -> Grid<(usize, usize)> {
228 let c_term_deprotonated = c_terminus_is_deprotonated(target_ph);
229
230 let atoms: Vec<(Point, (usize, usize))> = structure
231 .par_chains()
232 .enumerate()
233 .flat_map(|(c_idx, chain)| {
234 chain
235 .par_residues()
236 .enumerate()
237 .flat_map_iter(move |(r_idx, residue)| {
238 let mut positions = Vec::new();
239
240 if residue.name == "ASP" {
242 if let Some(od1) = residue.atom("OD1") {
243 positions.push((od1.pos, (c_idx, r_idx)));
244 }
245 if let Some(od2) = residue.atom("OD2") {
246 positions.push((od2.pos, (c_idx, r_idx)));
247 }
248 }
249
250 if residue.name == "GLU" {
252 if let Some(oe1) = residue.atom("OE1") {
253 positions.push((oe1.pos, (c_idx, r_idx)));
254 }
255 if let Some(oe2) = residue.atom("OE2") {
256 positions.push((oe2.pos, (c_idx, r_idx)));
257 }
258 }
259
260 if residue.position == ResiduePosition::CTerminal
262 && residue.standard_name.is_some_and(|s| s.is_protein())
263 && c_term_deprotonated
264 {
265 if let Some(o) = residue.atom("O") {
266 positions.push((o.pos, (c_idx, r_idx)));
267 }
268 if let Some(oxt) = residue.atom("OXT") {
269 positions.push((oxt.pos, (c_idx, r_idx)));
270 }
271 }
272
273 positions
274 })
275 })
276 .collect();
277
278 Grid::new(atoms, SALT_BRIDGE_DISTANCE + 0.5)
279}
280
281fn determine_his_protonation(
303 residue: &Residue,
304 config: &HydroConfig,
305 acceptor_grid: Option<&Grid<(usize, usize)>>,
306 carboxylate_grid: Option<&Grid<(usize, usize)>>,
307 self_indices: (usize, usize),
308) -> Option<String> {
309 if let Some(ph) = config.target_ph
310 && ph < HIS_HIP_PKA
311 {
312 return Some("HIP".to_string());
313 }
314
315 if config.target_ph.is_none() && !config.his_salt_bridge_protonation {
316 return None;
317 }
318
319 if config.his_salt_bridge_protonation
320 && let Some(grid) = carboxylate_grid
321 && his_forms_salt_bridge(residue, grid, self_indices)
322 {
323 return Some("HIP".to_string());
324 }
325
326 config.target_ph?;
327
328 Some(select_neutral_his(
329 residue,
330 config.his_strategy,
331 acceptor_grid,
332 self_indices,
333 ))
334}
335
336fn his_forms_salt_bridge(
351 residue: &Residue,
352 carboxylate_grid: &Grid<(usize, usize)>,
353 self_indices: (usize, usize),
354) -> bool {
355 if let Some(nd1) = residue.atom("ND1") {
357 for (_, &idx) in carboxylate_grid
358 .neighbors(&nd1.pos, SALT_BRIDGE_DISTANCE)
359 .exact()
360 {
361 if idx != self_indices {
362 return true;
363 }
364 }
365 }
366
367 if let Some(ne2) = residue.atom("NE2") {
369 for (_, &idx) in carboxylate_grid
370 .neighbors(&ne2.pos, SALT_BRIDGE_DISTANCE)
371 .exact()
372 {
373 if idx != self_indices {
374 return true;
375 }
376 }
377 }
378
379 false
380}
381
382fn select_neutral_his(
395 residue: &Residue,
396 strategy: HisStrategy,
397 acceptor_grid: Option<&Grid<(usize, usize)>>,
398 self_indices: (usize, usize),
399) -> String {
400 match strategy {
401 HisStrategy::DirectHID => "HID".to_string(),
402 HisStrategy::DirectHIE => "HIE".to_string(),
403 HisStrategy::Random => {
404 let mut rng = rand::rng();
405 if rng.random_bool(0.5) {
406 "HID".to_string()
407 } else {
408 "HIE".to_string()
409 }
410 }
411 HisStrategy::HbNetwork => optimize_his_network(residue, acceptor_grid, self_indices),
412 }
413}
414
415fn optimize_his_network(
430 residue: &Residue,
431 acceptor_grid: Option<&Grid<(usize, usize)>>,
432 self_indices: (usize, usize),
433) -> String {
434 let grid = match acceptor_grid {
435 Some(g) => g,
436 None => return "HIE".to_string(),
437 };
438
439 let score_hid = calculate_h_bond_score(residue, "ND1", "CG", "CE1", grid, self_indices);
440 let score_hie = calculate_h_bond_score(residue, "NE2", "CD2", "CE1", grid, self_indices);
441
442 if score_hid > score_hie {
443 "HID".to_string()
444 } else {
445 "HIE".to_string()
446 }
447}
448
449fn calculate_h_bond_score(
467 residue: &Residue,
468 n_name: &str,
469 c1_name: &str,
470 c2_name: &str,
471 grid: &Grid<(usize, usize)>,
472 self_idx: (usize, usize),
473) -> f64 {
474 let n = match residue.atom(n_name) {
475 Some(a) => a,
476 None => return 0.0,
477 };
478 let c1 = match residue.atom(c1_name) {
479 Some(a) => a,
480 None => return 0.0,
481 };
482 let c2 = match residue.atom(c2_name) {
483 Some(a) => a,
484 None => return 0.0,
485 };
486
487 let v1 = (c1.pos - n.pos).normalize();
488 let v2 = (c2.pos - n.pos).normalize();
489 let bisector = (v1 + v2).normalize();
490 let h_dir = -bisector;
491 let h_pos = n.pos + h_dir;
492
493 let mut score = 0.0;
494
495 for (a_pos, &idx) in grid.neighbors(&n.pos, 3.5).exact() {
496 if idx == self_idx {
497 continue;
498 }
499
500 let h_a_vec = a_pos - h_pos;
501 let dist_sq = h_a_vec.norm_squared();
502
503 if dist_sq > 2.7 * 2.7 {
504 continue;
505 }
506
507 let h_a_dir = h_a_vec.normalize();
508 let cos_theta = h_dir.dot(&h_a_dir);
509
510 if cos_theta > 0.0 {
511 score += (1.0 / dist_sq) * (cos_theta * cos_theta);
512 }
513 }
514
515 score
516}
517
518fn mark_disulfide_bridges(structure: &mut Structure) {
524 let cys_sulfurs: Vec<(Point, (usize, usize))> = structure
525 .par_chains_mut()
526 .enumerate()
527 .flat_map(|(c_idx, chain)| {
528 chain
529 .par_residues_mut()
530 .enumerate()
531 .filter_map(move |(r_idx, residue)| {
532 if matches!(residue.standard_name, Some(StandardResidue::CYS)) {
533 residue.atom("SG").map(|sg| (sg.pos, (c_idx, r_idx)))
534 } else {
535 None
536 }
537 })
538 })
539 .collect();
540
541 if cys_sulfurs.len() < 2 {
542 return;
543 }
544
545 let grid = Grid::new(cys_sulfurs.clone(), DISULFIDE_SG_THRESHOLD + 0.5);
546
547 let disulfide_residues: HashSet<(usize, usize)> = cys_sulfurs
548 .par_iter()
549 .flat_map_iter(|(pos, (c_idx, r_idx))| {
550 grid.neighbors(pos, DISULFIDE_SG_THRESHOLD)
551 .exact()
552 .filter_map(move |(_, &(neighbor_c, neighbor_r))| {
553 if *c_idx == neighbor_c && *r_idx == neighbor_r {
554 None
555 } else {
556 Some([(*c_idx, *r_idx), (neighbor_c, neighbor_r)])
557 }
558 })
559 })
560 .flatten()
561 .collect();
562
563 if disulfide_residues.is_empty() {
564 return;
565 }
566
567 structure
568 .par_chains_mut()
569 .enumerate()
570 .for_each(|(c_idx, chain)| {
571 chain
572 .par_residues_mut()
573 .enumerate()
574 .for_each(|(r_idx, residue)| {
575 if disulfide_residues.contains(&(c_idx, r_idx)) && residue.name != "CYX" {
576 residue.name = "CYX".into();
577 }
578 });
579 });
580}
581
582fn build_acceptor_grid(structure: &Structure) -> Grid<(usize, usize)> {
592 let atoms: Vec<(Point, (usize, usize))> = structure
593 .par_chains()
594 .enumerate()
595 .flat_map(|(c_idx, chain)| {
596 chain
597 .par_residues()
598 .enumerate()
599 .flat_map_iter(move |(r_idx, residue)| {
600 residue
601 .iter_atoms()
602 .filter(|a| matches!(a.element, Element::N | Element::O | Element::F))
603 .map(move |a| (a.pos, (c_idx, r_idx)))
604 })
605 })
606 .collect();
607
608 Grid::new(atoms, 3.5)
609}
610
611fn construct_hydrogens_for_residue(
627 residue: &mut Residue,
628 config: &HydroConfig,
629) -> Result<(), Error> {
630 let template_name = residue.name.clone();
631
632 let template_view =
633 db::get_template(&template_name).ok_or_else(|| Error::MissingInternalTemplate {
634 res_name: template_name.to_string(),
635 })?;
636
637 let existing_atoms: HashSet<String> =
638 residue.atoms().iter().map(|a| a.name.to_string()).collect();
639
640 let rotation_override = if residue.standard_name == Some(StandardResidue::HOH) {
641 let mut rng = rand::rng();
642 Some(
643 Rotation3::from_axis_angle(
644 &Vector3::y_axis(),
645 rng.random_range(0.0..std::f64::consts::TAU),
646 ) * Rotation3::from_axis_angle(
647 &Vector3::x_axis(),
648 rng.random_range(0.0..std::f64::consts::TAU),
649 ),
650 )
651 } else {
652 None
653 };
654
655 for (h_name, h_tmpl_pos, anchors_iter) in template_view.hydrogens() {
656 if existing_atoms.contains(h_name) {
657 continue;
658 }
659
660 let anchors: Vec<&str> = anchors_iter.collect();
661 if let Ok(pos) = reconstruct_geometry(residue, h_tmpl_pos, &anchors, rotation_override) {
662 residue.add_atom(Atom::new(h_name, Element::H, pos));
663 } else {
664 return Err(Error::incomplete_for_hydro(
665 &*residue.name,
666 residue.id,
667 anchors.first().copied().unwrap_or("?"),
668 ));
669 }
670 }
671
672 match residue.position {
673 ResiduePosition::NTerminal if residue.standard_name.is_some_and(|s| s.is_protein()) => {
674 construct_n_term_hydrogens(residue, n_term_is_protonated(config.target_ph))?;
675 }
676 ResiduePosition::CTerminal if residue.standard_name.is_some_and(|s| s.is_protein()) => {
677 construct_c_term_hydrogen(residue, c_term_is_protonated(config.target_ph))?;
678 }
679 ResiduePosition::ThreePrime if residue.standard_name.is_some_and(|s| s.is_nucleic()) => {
680 construct_3_prime_hydrogen(residue)?;
681 }
682 ResiduePosition::FivePrime if residue.standard_name.is_some_and(|s| s.is_nucleic()) => {
683 if residue.has_atom("P") {
684 construct_5_prime_phosphate_hydrogens(residue, config.target_ph)?;
685 } else if residue.has_atom("O5'") {
686 construct_5_prime_hydrogen(residue)?;
687 }
688 }
689 _ => {}
690 }
691
692 Ok(())
693}
694
695#[inline]
697fn effective_terminal_ph(target_ph: Option<f64>) -> f64 {
698 target_ph.unwrap_or(DEFAULT_TERMINAL_PH)
699}
700
701#[inline]
703fn c_terminus_is_deprotonated(target_ph: Option<f64>) -> bool {
704 effective_terminal_ph(target_ph) >= C_TERM_PKA
705}
706
707#[inline]
709fn n_term_is_protonated(target_ph: Option<f64>) -> bool {
710 effective_terminal_ph(target_ph) <= N_TERM_PKA
711}
712
713#[inline]
715fn c_term_is_protonated(target_ph: Option<f64>) -> bool {
716 effective_terminal_ph(target_ph) < C_TERM_PKA
717}
718
719fn construct_n_term_hydrogens(residue: &mut Residue, protonated: bool) -> Result<(), Error> {
721 residue.remove_atom("H");
722 residue.remove_atom("H1");
723 residue.remove_atom("H2");
724 residue.remove_atom("H3");
725
726 let n_pos = residue
727 .atom("N")
728 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "N"))?
729 .pos;
730 let ca_pos = residue
731 .atom("CA")
732 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "CA"))?
733 .pos;
734
735 let (x, y, z) = build_sp3_frame(n_pos, ca_pos, None);
736
737 let theta = SP3_ANGLE.to_radians();
738 let sin_theta = theta.sin();
739 let cos_theta = theta.cos();
740
741 let phases = [0.0_f64, 120.0, 240.0];
742 let target_count = if protonated { 3 } else { 2 };
743 let names = ["H1", "H2", "H3"];
744
745 for (idx, phase) in phases.iter().take(target_count).enumerate() {
746 let phi = phase.to_radians();
747 let h_local = Vector3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), -cos_theta);
748 let h_global = x * h_local.x + y * h_local.y + z * h_local.z;
749 let h_pos = n_pos + h_global * NH_BOND_LENGTH;
750 residue.add_atom(Atom::new(names[idx], Element::H, h_pos));
751 }
752
753 Ok(())
754}
755
756fn construct_c_term_hydrogen(residue: &mut Residue, protonated: bool) -> Result<(), Error> {
758 if !protonated {
759 residue.remove_atom("HOXT");
760 residue.remove_atom("HXT");
761 return Ok(());
762 }
763
764 residue.remove_atom("HXT");
765 if residue.has_atom("HOXT") {
766 return Ok(());
767 }
768
769 let c_pos = residue
770 .atom("C")
771 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "C"))?
772 .pos;
773 let oxt_pos = residue
774 .atom("OXT")
775 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "OXT"))?
776 .pos;
777
778 let c_oxt_dist = (oxt_pos - c_pos).norm();
779 if c_oxt_dist < 1e-6 {
780 return Err(Error::incomplete_for_hydro(
781 &*residue.name,
782 residue.id,
783 "OXT",
784 ));
785 }
786
787 let reference_pos = residue
788 .atom("CA")
789 .or_else(|| residue.atom("O"))
790 .map(|a| a.pos);
791
792 let h_pos = place_hydroxyl_hydrogen(
793 oxt_pos,
794 c_pos,
795 reference_pos,
796 COOH_BOND_LENGTH,
797 SP3_ANGLE,
798 60.0,
799 );
800
801 residue.add_atom(Atom::new("HOXT", Element::H, h_pos));
802 Ok(())
803}
804
805fn construct_3_prime_hydrogen(residue: &mut Residue) -> Result<(), Error> {
807 if residue.has_atom("HO3'") {
808 return Ok(());
809 }
810
811 let o3_pos = residue
812 .atom("O3'")
813 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "O3'"))?
814 .pos;
815 let c3_pos = residue
816 .atom("C3'")
817 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "C3'"))?
818 .pos;
819
820 let reference_pos = residue
821 .atom("C4'")
822 .or_else(|| residue.atom("C2'"))
823 .map(|a| a.pos);
824
825 let h_pos = place_hydroxyl_hydrogen(
826 o3_pos,
827 c3_pos,
828 reference_pos,
829 OH_BOND_LENGTH,
830 SP3_ANGLE,
831 180.0,
832 );
833
834 residue.add_atom(Atom::new("HO3'", Element::H, h_pos));
835 Ok(())
836}
837
838fn construct_5_prime_hydrogen(residue: &mut Residue) -> Result<(), Error> {
840 if residue.has_atom("HO5'") {
841 return Ok(());
842 }
843
844 let o5_pos = residue
845 .atom("O5'")
846 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "O5'"))?
847 .pos;
848 let c5_pos = residue
849 .atom("C5'")
850 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "C5'"))?
851 .pos;
852
853 let reference_pos = residue.atom("C4'").map(|a| a.pos);
854
855 let h_pos = place_hydroxyl_hydrogen(
856 o5_pos,
857 c5_pos,
858 reference_pos,
859 OH_BOND_LENGTH,
860 SP3_ANGLE,
861 180.0,
862 );
863
864 residue.add_atom(Atom::new("HO5'", Element::H, h_pos));
865 Ok(())
866}
867
868fn construct_5_prime_phosphate_hydrogens(
873 residue: &mut Residue,
874 target_ph: Option<f64>,
875) -> Result<(), Error> {
876 let ph = effective_terminal_ph(target_ph);
877
878 if ph >= PHOSPHATE_PKA2 {
879 residue.remove_atom("HOP3");
880 residue.remove_atom("HOP2");
881 return Ok(());
882 }
883
884 if residue.has_atom("HOP3") {
885 return Ok(());
886 }
887
888 let op3_pos = residue
889 .atom("OP3")
890 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "OP3"))?
891 .pos;
892 let p_pos = residue
893 .atom("P")
894 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "P"))?
895 .pos;
896
897 let reference_pos = residue
898 .atom("OP1")
899 .or_else(|| residue.atom("OP2"))
900 .map(|a| a.pos);
901
902 let h_pos = place_hydroxyl_hydrogen(
903 op3_pos,
904 p_pos,
905 reference_pos,
906 OH_BOND_LENGTH,
907 SP3_ANGLE,
908 180.0,
909 );
910
911 residue.add_atom(Atom::new("HOP3", Element::H, h_pos));
912 Ok(())
913}
914
915fn reconstruct_geometry(
917 residue: &Residue,
918 target_tmpl_pos: Point,
919 anchor_names: &[&str],
920 rotation_override: Option<Rotation3<f64>>,
921) -> Result<Point, ()> {
922 let template_view = db::get_template(&residue.name).ok_or(())?;
923
924 let mut residue_pts = Vec::new();
925 let mut template_pts = Vec::new();
926
927 for name in anchor_names {
928 let r_atom = residue.atom(name).ok_or(())?;
929 residue_pts.push(r_atom.pos);
930
931 let t_pos = template_view
932 .heavy_atoms()
933 .find(|(n, _, _)| n == name)
934 .map(|(_, _, p)| p)
935 .ok_or(())?;
936 template_pts.push(t_pos);
937 }
938
939 let (mut rot, mut trans) = calculate_transform(&residue_pts, &template_pts).ok_or(())?;
940
941 if let (Some(override_rot), 1) = (rotation_override, anchor_names.len()) {
942 rot = override_rot.into_inner();
943 trans = residue_pts[0].coords - rot * template_pts[0].coords;
944 }
945
946 Ok(rot * target_tmpl_pos + trans)
947}
948
949fn build_sp3_frame(
952 center: Point,
953 attached: Point,
954 reference: Option<Point>,
955) -> (Vector3<f64>, Vector3<f64>, Vector3<f64>) {
956 let z = (center - attached).normalize();
957
958 let ref_vec = reference
959 .map(|r| (r - attached).normalize())
960 .unwrap_or_else(|| {
961 if z.x.abs() < z.y.abs() && z.x.abs() < z.z.abs() {
962 Vector3::x()
963 } else if z.y.abs() < z.z.abs() {
964 Vector3::y()
965 } else {
966 Vector3::z()
967 }
968 });
969
970 let x = (ref_vec - z * z.dot(&ref_vec)).normalize();
971 let y = z.cross(&x);
972
973 (x, y, z)
974}
975
976fn place_hydroxyl_hydrogen(
978 o_pos: Point,
979 attached_pos: Point,
980 reference_pos: Option<Point>,
981 bond_length: f64,
982 bond_angle: f64,
983 dihedral_offset: f64,
984) -> Point {
985 let (x, y, z) = build_sp3_frame(o_pos, attached_pos, reference_pos);
986
987 let theta = bond_angle.to_radians();
988 let phi = dihedral_offset.to_radians();
989
990 let sin_theta = theta.sin();
991 let cos_theta = theta.cos();
992
993 let h_local = Vector3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), -cos_theta);
994 let h_global = x * h_local.x + y * h_local.y + z * h_local.z;
995
996 o_pos + h_global * bond_length
997}
998
999fn calculate_transform(r_pts: &[Point], t_pts: &[Point]) -> Option<(Matrix3<f64>, Vector3<f64>)> {
1003 let n = r_pts.len();
1004 if n != t_pts.len() || n == 0 {
1005 return None;
1006 }
1007
1008 let r_center = r_pts.iter().map(|p| p.coords).sum::<Vector3<f64>>() / n as f64;
1009 let t_center = t_pts.iter().map(|p| p.coords).sum::<Vector3<f64>>() / n as f64;
1010
1011 if n == 1 {
1012 return Some((Matrix3::identity(), r_center - t_center));
1013 }
1014
1015 if n == 2 {
1016 let v_r = r_pts[1] - r_pts[0];
1017 let v_t = t_pts[1] - t_pts[0];
1018 let rot = Rotation3::rotation_between(&v_t, &v_r).unwrap_or_else(Rotation3::identity);
1019 let trans = r_center - rot * t_center;
1020 return Some((rot.into_inner(), trans));
1021 }
1022
1023 let mut cov = Matrix3::zeros();
1024 for (p_r, p_t) in r_pts.iter().zip(t_pts.iter()) {
1025 let v_r = p_r.coords - r_center;
1026 let v_t = p_t.coords - t_center;
1027 cov += v_r * v_t.transpose();
1028 }
1029
1030 let svd = cov.svd(true, true);
1031 let u = svd.u?;
1032 let v_t = svd.v_t?;
1033
1034 let mut rot = u * v_t;
1035 if rot.determinant() < 0.0 {
1036 let mut corr = Matrix3::identity();
1037 corr[(2, 2)] = -1.0;
1038 rot = u * corr * v_t;
1039 }
1040
1041 let trans = r_center - rot * t_center;
1042 Some((rot, trans))
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use crate::model::{
1049 atom::Atom,
1050 chain::Chain,
1051 residue::Residue,
1052 types::{Element, Point, ResidueCategory, ResiduePosition, StandardResidue},
1053 };
1054
1055 fn residue_from_template(name: &str, std: StandardResidue, id: i32) -> Residue {
1056 let template = db::get_template(name).unwrap_or_else(|| panic!("missing template {name}"));
1057 let mut residue = Residue::new(id, None, name, Some(std), ResidueCategory::Standard);
1058 residue.position = ResiduePosition::Internal;
1059 for (atom_name, element, pos) in template.heavy_atoms() {
1060 residue.add_atom(Atom::new(atom_name, element, pos));
1061 }
1062 residue
1063 }
1064
1065 fn structure_with_residue(residue: Residue) -> Structure {
1066 let mut chain = Chain::new("A");
1067 chain.add_residue(residue);
1068 let mut structure = Structure::new();
1069 structure.add_chain(chain);
1070 structure
1071 }
1072
1073 fn structure_with_residues(residues: Vec<Residue>) -> Structure {
1074 let mut chain = Chain::new("A");
1075 for residue in residues {
1076 chain.add_residue(residue);
1077 }
1078 let mut structure = Structure::new();
1079 structure.add_chain(chain);
1080 structure
1081 }
1082
1083 fn n_terminal_residue(id: i32) -> Residue {
1084 let mut residue = residue_from_template("ALA", StandardResidue::ALA, id);
1085 residue.position = ResiduePosition::NTerminal;
1086 residue
1087 }
1088
1089 fn c_terminal_residue(id: i32) -> Residue {
1090 let mut residue = residue_from_template("ALA", StandardResidue::ALA, id);
1091 residue.position = ResiduePosition::CTerminal;
1092 let c_pos = residue.atom("C").expect("C atom").pos;
1093 let o_pos = residue.atom("O").expect("O atom").pos;
1094 let offset = c_pos - o_pos;
1095 let oxt_pos = c_pos + offset;
1096 residue.add_atom(Atom::new("OXT", Element::O, oxt_pos));
1097 residue
1098 }
1099
1100 fn five_prime_residue_with_phosphate(id: i32) -> Residue {
1101 let template = db::get_template("DA").unwrap();
1102 let mut residue = Residue::new(
1103 id,
1104 None,
1105 "DA",
1106 Some(StandardResidue::DA),
1107 ResidueCategory::Standard,
1108 );
1109 residue.position = ResiduePosition::FivePrime;
1110 for (atom_name, element, pos) in template.heavy_atoms() {
1111 residue.add_atom(Atom::new(atom_name, element, pos));
1112 }
1113 let p_pos = residue.atom("P").unwrap().pos;
1114 let op1_pos = residue.atom("OP1").unwrap().pos;
1115 let op2_pos = residue.atom("OP2").unwrap().pos;
1116 let o5_pos = residue.atom("O5'").unwrap().pos;
1117 let centroid = (op1_pos.coords + op2_pos.coords + o5_pos.coords) / 3.0;
1118 let direction = (p_pos.coords - centroid).normalize();
1119 let op3_pos = p_pos + direction * 1.48;
1120 residue.add_atom(Atom::new("OP3", Element::O, op3_pos));
1121 residue
1122 }
1123
1124 fn five_prime_residue_without_phosphate(id: i32) -> Residue {
1125 let template = db::get_template("DA").unwrap();
1126 let mut residue = Residue::new(
1127 id,
1128 None,
1129 "DA",
1130 Some(StandardResidue::DA),
1131 ResidueCategory::Standard,
1132 );
1133 residue.position = ResiduePosition::FivePrime;
1134 for (atom_name, element, pos) in template.heavy_atoms() {
1135 if !matches!(atom_name, "P" | "OP1" | "OP2") {
1136 residue.add_atom(Atom::new(atom_name, element, pos));
1137 }
1138 }
1139 residue
1140 }
1141
1142 fn three_prime_residue(id: i32) -> Residue {
1143 let template = db::get_template("DA").unwrap();
1144 let mut residue = Residue::new(
1145 id,
1146 None,
1147 "DA",
1148 Some(StandardResidue::DA),
1149 ResidueCategory::Standard,
1150 );
1151 residue.position = ResiduePosition::ThreePrime;
1152 for (atom_name, element, pos) in template.heavy_atoms() {
1153 residue.add_atom(Atom::new(atom_name, element, pos));
1154 }
1155 residue
1156 }
1157
1158 fn his_near_asp(his_id: i32, asp_id: i32, distance: f64) -> Structure {
1159 let his = residue_from_template("HID", StandardResidue::HIS, his_id);
1160 let mut asp = residue_from_template("ASP", StandardResidue::ASP, asp_id);
1161
1162 let his_nd1 = his.atom("ND1").expect("ND1").pos;
1163 let asp_od1 = asp.atom("OD1").expect("OD1").pos;
1164 let offset = his_nd1 + Vector3::new(distance, 0.0, 0.0) - asp_od1;
1165 for atom in asp.iter_atoms_mut() {
1166 atom.translate_by(&offset);
1167 }
1168
1169 structure_with_residues(vec![his, asp])
1170 }
1171
1172 fn his_near_glu(his_id: i32, glu_id: i32, distance: f64) -> Structure {
1173 let his = residue_from_template("HID", StandardResidue::HIS, his_id);
1174 let mut glu = residue_from_template("GLU", StandardResidue::GLU, glu_id);
1175
1176 let his_ne2 = his.atom("NE2").expect("NE2").pos;
1177 let glu_oe1 = glu.atom("OE1").expect("OE1").pos;
1178 let offset = his_ne2 + Vector3::new(distance, 0.0, 0.0) - glu_oe1;
1179 for atom in glu.iter_atoms_mut() {
1180 atom.translate_by(&offset);
1181 }
1182
1183 structure_with_residues(vec![his, glu])
1184 }
1185
1186 fn his_near_c_term(his_id: i32, c_term_id: i32, distance: f64) -> Structure {
1187 let his = residue_from_template("HID", StandardResidue::HIS, his_id);
1188 let mut c_term = c_terminal_residue(c_term_id);
1189
1190 let his_nd1 = his.atom("ND1").expect("ND1").pos;
1191 let c_term_oxt = c_term.atom("OXT").expect("OXT").pos;
1192 let offset = his_nd1 + Vector3::new(distance, 0.0, 0.0) - c_term_oxt;
1193 for atom in c_term.iter_atoms_mut() {
1194 atom.translate_by(&offset);
1195 }
1196
1197 structure_with_residues(vec![his, c_term])
1198 }
1199
1200 fn his_isolated(id: i32) -> Structure {
1201 let his = residue_from_template("HID", StandardResidue::HIS, id);
1202 structure_with_residue(his)
1203 }
1204
1205 fn distance(a: Point, b: Point) -> f64 {
1206 (a - b).norm()
1207 }
1208
1209 fn angle_deg(a: Point, center: Point, b: Point) -> f64 {
1210 let v1 = (a - center).normalize();
1211 let v2 = (b - center).normalize();
1212 v1.dot(&v2).clamp(-1.0, 1.0).acos().to_degrees()
1213 }
1214
1215 #[test]
1216 fn asp_protonates_to_ash_below_pka() {
1217 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1218 let mut structure = structure_with_residue(residue);
1219 let config = HydroConfig {
1220 target_ph: Some(2.5),
1221 ..HydroConfig::default()
1222 };
1223
1224 add_hydrogens(&mut structure, &config).unwrap();
1225
1226 let res = structure.find_residue("A", 1, None).unwrap();
1227 assert_eq!(res.name, "ASH", "ASP should become ASH below pKa 3.9");
1228 }
1229
1230 #[test]
1231 fn asp_remains_deprotonated_above_pka() {
1232 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1233 let mut structure = structure_with_residue(residue);
1234 let config = HydroConfig {
1235 target_ph: Some(7.4),
1236 ..HydroConfig::default()
1237 };
1238
1239 add_hydrogens(&mut structure, &config).unwrap();
1240
1241 let res = structure.find_residue("A", 1, None).unwrap();
1242 assert_eq!(res.name, "ASP", "ASP should remain ASP above pKa 3.9");
1243 }
1244
1245 #[test]
1246 fn asp_preserves_original_name_without_ph() {
1247 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1248 let mut structure = structure_with_residue(residue);
1249 let config = HydroConfig {
1250 target_ph: None,
1251 his_salt_bridge_protonation: false,
1252 ..HydroConfig::default()
1253 };
1254
1255 add_hydrogens(&mut structure, &config).unwrap();
1256
1257 let res = structure.find_residue("A", 1, None).unwrap();
1258 assert_eq!(res.name, "ASP", "ASP should be preserved without pH");
1259 }
1260
1261 #[test]
1262 fn glu_protonates_to_glh_below_pka() {
1263 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1264 let mut structure = structure_with_residue(residue);
1265 let config = HydroConfig {
1266 target_ph: Some(3.0),
1267 ..HydroConfig::default()
1268 };
1269
1270 add_hydrogens(&mut structure, &config).unwrap();
1271
1272 let res = structure.find_residue("A", 1, None).unwrap();
1273 assert_eq!(res.name, "GLH", "GLU should become GLH below pKa 4.2");
1274 }
1275
1276 #[test]
1277 fn glu_remains_deprotonated_above_pka() {
1278 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1279 let mut structure = structure_with_residue(residue);
1280 let config = HydroConfig {
1281 target_ph: Some(7.4),
1282 ..HydroConfig::default()
1283 };
1284
1285 add_hydrogens(&mut structure, &config).unwrap();
1286
1287 let res = structure.find_residue("A", 1, None).unwrap();
1288 assert_eq!(res.name, "GLU", "GLU should remain GLU above pKa 4.2");
1289 }
1290
1291 #[test]
1292 fn glu_preserves_original_name_without_ph() {
1293 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1294 let mut structure = structure_with_residue(residue);
1295 let config = HydroConfig {
1296 target_ph: None,
1297 his_salt_bridge_protonation: false,
1298 ..HydroConfig::default()
1299 };
1300
1301 add_hydrogens(&mut structure, &config).unwrap();
1302
1303 let res = structure.find_residue("A", 1, None).unwrap();
1304 assert_eq!(res.name, "GLU", "GLU should be preserved without pH");
1305 }
1306
1307 #[test]
1308 fn lys_deprotonates_to_lyn_above_pka() {
1309 let residue = residue_from_template("LYS", StandardResidue::LYS, 1);
1310 let mut structure = structure_with_residue(residue);
1311 let config = HydroConfig {
1312 target_ph: Some(11.0),
1313 ..HydroConfig::default()
1314 };
1315
1316 add_hydrogens(&mut structure, &config).unwrap();
1317
1318 let res = structure.find_residue("A", 1, None).unwrap();
1319 assert_eq!(res.name, "LYN", "LYS should become LYN above pKa 10.5");
1320 }
1321
1322 #[test]
1323 fn lys_remains_protonated_below_pka() {
1324 let residue = residue_from_template("LYS", StandardResidue::LYS, 1);
1325 let mut structure = structure_with_residue(residue);
1326 let config = HydroConfig {
1327 target_ph: Some(7.4),
1328 ..HydroConfig::default()
1329 };
1330
1331 add_hydrogens(&mut structure, &config).unwrap();
1332
1333 let res = structure.find_residue("A", 1, None).unwrap();
1334 assert_eq!(res.name, "LYS", "LYS should remain LYS below pKa 10.5");
1335 }
1336
1337 #[test]
1338 fn lys_preserves_original_name_without_ph() {
1339 let residue = residue_from_template("LYS", StandardResidue::LYS, 1);
1340 let mut structure = structure_with_residue(residue);
1341 let config = HydroConfig {
1342 target_ph: None,
1343 his_salt_bridge_protonation: false,
1344 ..HydroConfig::default()
1345 };
1346
1347 add_hydrogens(&mut structure, &config).unwrap();
1348
1349 let res = structure.find_residue("A", 1, None).unwrap();
1350 assert_eq!(res.name, "LYS", "LYS should be preserved without pH");
1351 }
1352
1353 #[test]
1354 fn cys_deprotonates_to_cym_above_pka() {
1355 let residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1356 let mut structure = structure_with_residue(residue);
1357 let config = HydroConfig {
1358 target_ph: Some(9.0),
1359 ..HydroConfig::default()
1360 };
1361
1362 add_hydrogens(&mut structure, &config).unwrap();
1363
1364 let res = structure.find_residue("A", 1, None).unwrap();
1365 assert_eq!(res.name, "CYM", "CYS should become CYM above pKa 8.3");
1366 }
1367
1368 #[test]
1369 fn cys_remains_protonated_below_pka() {
1370 let residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1371 let mut structure = structure_with_residue(residue);
1372 let config = HydroConfig {
1373 target_ph: Some(7.4),
1374 ..HydroConfig::default()
1375 };
1376
1377 add_hydrogens(&mut structure, &config).unwrap();
1378
1379 let res = structure.find_residue("A", 1, None).unwrap();
1380 assert_eq!(res.name, "CYS", "CYS should remain CYS below pKa 8.3");
1381 }
1382
1383 #[test]
1384 fn cys_preserves_original_name_without_ph() {
1385 let residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1386 let mut structure = structure_with_residue(residue);
1387 let config = HydroConfig {
1388 target_ph: None,
1389 his_salt_bridge_protonation: false,
1390 ..HydroConfig::default()
1391 };
1392
1393 add_hydrogens(&mut structure, &config).unwrap();
1394
1395 let res = structure.find_residue("A", 1, None).unwrap();
1396 assert_eq!(res.name, "CYS", "CYS should be preserved without pH");
1397 }
1398
1399 #[test]
1400 fn cyx_is_preserved_regardless_of_ph() {
1401 let mut residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1402 residue.name = "CYX".into();
1403 let mut structure = structure_with_residue(residue);
1404 let config = HydroConfig {
1405 target_ph: Some(9.0),
1406 ..HydroConfig::default()
1407 };
1408
1409 add_hydrogens(&mut structure, &config).unwrap();
1410
1411 let res = structure.find_residue("A", 1, None).unwrap();
1412 assert_eq!(res.name, "CYX", "CYX should be preserved regardless of pH");
1413 }
1414
1415 #[test]
1416 fn tyr_deprotonates_to_tym_above_pka() {
1417 let residue = residue_from_template("TYR", StandardResidue::TYR, 1);
1418 let mut structure = structure_with_residue(residue);
1419 let config = HydroConfig {
1420 target_ph: Some(11.0),
1421 ..HydroConfig::default()
1422 };
1423
1424 add_hydrogens(&mut structure, &config).unwrap();
1425
1426 let res = structure.find_residue("A", 1, None).unwrap();
1427 assert_eq!(res.name, "TYM", "TYR should become TYM above pKa 10.0");
1428 }
1429
1430 #[test]
1431 fn tyr_remains_protonated_below_pka() {
1432 let residue = residue_from_template("TYR", StandardResidue::TYR, 1);
1433 let mut structure = structure_with_residue(residue);
1434 let config = HydroConfig {
1435 target_ph: Some(7.4),
1436 ..HydroConfig::default()
1437 };
1438
1439 add_hydrogens(&mut structure, &config).unwrap();
1440
1441 let res = structure.find_residue("A", 1, None).unwrap();
1442 assert_eq!(res.name, "TYR", "TYR should remain TYR below pKa 10.0");
1443 }
1444
1445 #[test]
1446 fn tyr_preserves_original_name_without_ph() {
1447 let residue = residue_from_template("TYR", StandardResidue::TYR, 1);
1448 let mut structure = structure_with_residue(residue);
1449 let config = HydroConfig {
1450 target_ph: None,
1451 his_salt_bridge_protonation: false,
1452 ..HydroConfig::default()
1453 };
1454
1455 add_hydrogens(&mut structure, &config).unwrap();
1456
1457 let res = structure.find_residue("A", 1, None).unwrap();
1458 assert_eq!(res.name, "TYR", "TYR should be preserved without pH");
1459 }
1460
1461 #[test]
1462 fn arg_deprotonates_to_arn_above_pka() {
1463 let residue = residue_from_template("ARG", StandardResidue::ARG, 1);
1464 let mut structure = structure_with_residue(residue);
1465 let config = HydroConfig {
1466 target_ph: Some(13.0),
1467 ..HydroConfig::default()
1468 };
1469
1470 add_hydrogens(&mut structure, &config).unwrap();
1471
1472 let res = structure.find_residue("A", 1, None).unwrap();
1473 assert_eq!(res.name, "ARN", "ARG should become ARN above pKa 12.5");
1474 }
1475
1476 #[test]
1477 fn arg_remains_protonated_below_pka() {
1478 let residue = residue_from_template("ARG", StandardResidue::ARG, 1);
1479 let mut structure = structure_with_residue(residue);
1480 let config = HydroConfig {
1481 target_ph: Some(7.4),
1482 ..HydroConfig::default()
1483 };
1484
1485 add_hydrogens(&mut structure, &config).unwrap();
1486
1487 let res = structure.find_residue("A", 1, None).unwrap();
1488 assert_eq!(res.name, "ARG", "ARG should remain ARG below pKa 12.5");
1489 }
1490
1491 #[test]
1492 fn arg_preserves_original_name_without_ph() {
1493 let residue = residue_from_template("ARG", StandardResidue::ARG, 1);
1494 let mut structure = structure_with_residue(residue);
1495 let config = HydroConfig {
1496 target_ph: None,
1497 his_salt_bridge_protonation: false,
1498 ..HydroConfig::default()
1499 };
1500
1501 add_hydrogens(&mut structure, &config).unwrap();
1502
1503 let res = structure.find_residue("A", 1, None).unwrap();
1504 assert_eq!(res.name, "ARG", "ARG should be preserved without pH");
1505 }
1506
1507 #[test]
1508 fn coo_grid_includes_asp_oxygens_when_deprotonated() {
1509 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1510 let structure = structure_with_residue(residue);
1511
1512 let grid = build_carboxylate_grid(&structure, Some(7.4));
1513 let asp = structure.find_residue("A", 1, None).unwrap();
1514 let od1 = asp.atom("OD1").unwrap().pos;
1515
1516 let neighbors: Vec<_> = grid.neighbors(&od1, 0.1).exact().collect();
1517 assert!(!neighbors.is_empty(), "ASP OD1 should be in COO⁻ grid");
1518 }
1519
1520 #[test]
1521 fn coo_grid_excludes_ash_oxygens_when_protonated() {
1522 let mut residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1523 residue.name = "ASH".into();
1524 let structure = structure_with_residue(residue);
1525
1526 let grid = build_carboxylate_grid(&structure, Some(7.4));
1527 let ash = structure.find_residue("A", 1, None).unwrap();
1528 let od1 = ash.atom("OD1").unwrap().pos;
1529
1530 let neighbors: Vec<_> = grid.neighbors(&od1, 0.1).exact().collect();
1531 assert!(
1532 neighbors.is_empty(),
1533 "ASH oxygens should NOT be in COO⁻ grid"
1534 );
1535 }
1536
1537 #[test]
1538 fn coo_grid_includes_glu_oxygens_when_deprotonated() {
1539 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1540 let structure = structure_with_residue(residue);
1541
1542 let grid = build_carboxylate_grid(&structure, Some(7.4));
1543 let glu = structure.find_residue("A", 1, None).unwrap();
1544 let oe1 = glu.atom("OE1").unwrap().pos;
1545
1546 let neighbors: Vec<_> = grid.neighbors(&oe1, 0.1).exact().collect();
1547 assert!(!neighbors.is_empty(), "GLU OE1 should be in COO⁻ grid");
1548 }
1549
1550 #[test]
1551 fn coo_grid_excludes_glh_oxygens_when_protonated() {
1552 let mut residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1553 residue.name = "GLH".into();
1554 let structure = structure_with_residue(residue);
1555
1556 let grid = build_carboxylate_grid(&structure, Some(7.4));
1557 let glh = structure.find_residue("A", 1, None).unwrap();
1558 let oe1 = glh.atom("OE1").unwrap().pos;
1559
1560 let neighbors: Vec<_> = grid.neighbors(&oe1, 0.1).exact().collect();
1561 assert!(
1562 neighbors.is_empty(),
1563 "GLH oxygens should NOT be in COO⁻ grid"
1564 );
1565 }
1566
1567 #[test]
1568 fn coo_grid_includes_c_term_oxygens_at_neutral_ph() {
1569 let residue = c_terminal_residue(1);
1570 let structure = structure_with_residue(residue);
1571
1572 let grid = build_carboxylate_grid(&structure, Some(7.4));
1573 let c_term = structure.find_residue("A", 1, None).unwrap();
1574 let oxt = c_term.atom("OXT").unwrap().pos;
1575
1576 let neighbors: Vec<_> = grid.neighbors(&oxt, 0.1).exact().collect();
1577 assert!(
1578 !neighbors.is_empty(),
1579 "C-term OXT should be in COO⁻ grid at pH 7.4"
1580 );
1581 }
1582
1583 #[test]
1584 fn coo_grid_excludes_c_term_oxygens_below_pka() {
1585 let residue = c_terminal_residue(1);
1586 let structure = structure_with_residue(residue);
1587
1588 let grid = build_carboxylate_grid(&structure, Some(2.0));
1589 let c_term = structure.find_residue("A", 1, None).unwrap();
1590 let oxt = c_term.atom("OXT").unwrap().pos;
1591
1592 let neighbors: Vec<_> = grid.neighbors(&oxt, 0.1).exact().collect();
1593 assert!(
1594 neighbors.is_empty(),
1595 "C-term OXT should NOT be in COO⁻ grid below pKa 3.1"
1596 );
1597 }
1598
1599 #[test]
1600 fn coo_grid_uses_default_ph_when_target_ph_unset() {
1601 let residue = c_terminal_residue(1);
1602 let structure = structure_with_residue(residue);
1603
1604 let grid = build_carboxylate_grid(&structure, None);
1605 let c_term = structure.find_residue("A", 1, None).unwrap();
1606 let oxt = c_term.atom("OXT").unwrap().pos;
1607
1608 let neighbors: Vec<_> = grid.neighbors(&oxt, 0.1).exact().collect();
1609 assert!(
1610 !neighbors.is_empty(),
1611 "C-term OXT should be in COO⁻ grid with default pH 7.0"
1612 );
1613 }
1614
1615 #[test]
1616 fn his_becomes_hip_below_pka_threshold() {
1617 let mut structure = his_isolated(1);
1618 let config = HydroConfig {
1619 target_ph: Some(5.5),
1620 his_salt_bridge_protonation: false,
1621 ..HydroConfig::default()
1622 };
1623
1624 add_hydrogens(&mut structure, &config).unwrap();
1625
1626 let res = structure.find_residue("A", 1, None).unwrap();
1627 assert_eq!(res.name, "HIP", "HIS should become HIP below pKa 6.0");
1628 }
1629
1630 #[test]
1631 fn his_does_not_become_hip_above_pka_threshold() {
1632 let mut structure = his_isolated(1);
1633 let config = HydroConfig {
1634 target_ph: Some(7.4),
1635 his_salt_bridge_protonation: false,
1636 his_strategy: HisStrategy::DirectHIE,
1637 ..HydroConfig::default()
1638 };
1639
1640 add_hydrogens(&mut structure, &config).unwrap();
1641
1642 let res = structure.find_residue("A", 1, None).unwrap();
1643 assert_eq!(
1644 res.name, "HIE",
1645 "HIS should become HIE (not HIP) above pKa 6.0"
1646 );
1647 }
1648
1649 #[test]
1650 fn his_becomes_hip_when_nd1_near_asp_carboxylate() {
1651 let mut structure = his_near_asp(1, 2, 3.5);
1652 let config = HydroConfig {
1653 target_ph: Some(7.4),
1654 his_salt_bridge_protonation: true,
1655 ..HydroConfig::default()
1656 };
1657
1658 add_hydrogens(&mut structure, &config).unwrap();
1659
1660 let his = structure.find_residue("A", 1, None).unwrap();
1661 assert_eq!(
1662 his.name, "HIP",
1663 "HIS near ASP should become HIP via salt bridge"
1664 );
1665 }
1666
1667 #[test]
1668 fn his_becomes_hip_when_ne2_near_glu_carboxylate() {
1669 let mut structure = his_near_glu(1, 2, 3.5);
1670 let config = HydroConfig {
1671 target_ph: Some(7.4),
1672 his_salt_bridge_protonation: true,
1673 ..HydroConfig::default()
1674 };
1675
1676 add_hydrogens(&mut structure, &config).unwrap();
1677
1678 let his = structure.find_residue("A", 1, None).unwrap();
1679 assert_eq!(
1680 his.name, "HIP",
1681 "HIS near GLU should become HIP via salt bridge"
1682 );
1683 }
1684
1685 #[test]
1686 fn his_becomes_hip_when_near_c_term_carboxylate() {
1687 let mut structure = his_near_c_term(1, 2, 3.5);
1688 let config = HydroConfig {
1689 target_ph: Some(7.4),
1690 his_salt_bridge_protonation: true,
1691 ..HydroConfig::default()
1692 };
1693
1694 add_hydrogens(&mut structure, &config).unwrap();
1695
1696 let his = structure.find_residue("A", 1, None).unwrap();
1697 assert_eq!(
1698 his.name, "HIP",
1699 "HIS near C-term COO⁻ should become HIP via salt bridge"
1700 );
1701 }
1702
1703 #[test]
1704 fn his_remains_neutral_when_beyond_salt_bridge_threshold() {
1705 let mut structure = his_near_asp(1, 2, 10.0);
1706 let config = HydroConfig {
1707 target_ph: Some(7.4),
1708 his_salt_bridge_protonation: true,
1709 his_strategy: HisStrategy::DirectHIE,
1710 ..HydroConfig::default()
1711 };
1712
1713 add_hydrogens(&mut structure, &config).unwrap();
1714
1715 let his = structure.find_residue("A", 1, None).unwrap();
1716 assert_eq!(
1717 his.name, "HIE",
1718 "HIS beyond salt bridge distance should remain neutral"
1719 );
1720 }
1721
1722 #[test]
1723 fn his_salt_bridge_detected_without_ph() {
1724 let mut structure = his_near_asp(1, 2, 3.5);
1725 let config = HydroConfig {
1726 target_ph: None,
1727 his_salt_bridge_protonation: true,
1728 ..HydroConfig::default()
1729 };
1730
1731 add_hydrogens(&mut structure, &config).unwrap();
1732
1733 let his = structure.find_residue("A", 1, None).unwrap();
1734 assert_eq!(
1735 his.name, "HIP",
1736 "salt bridge should be detected even without pH"
1737 );
1738 }
1739
1740 #[test]
1741 fn his_salt_bridge_skipped_when_option_disabled() {
1742 let mut structure = his_near_asp(1, 2, 3.5);
1743 let config = HydroConfig {
1744 target_ph: Some(7.4),
1745 his_salt_bridge_protonation: false,
1746 his_strategy: HisStrategy::DirectHIE,
1747 ..HydroConfig::default()
1748 };
1749
1750 add_hydrogens(&mut structure, &config).unwrap();
1751
1752 let his = structure.find_residue("A", 1, None).unwrap();
1753 assert_eq!(
1754 his.name, "HIE",
1755 "salt bridge should be ignored when disabled"
1756 );
1757 }
1758
1759 #[test]
1760 fn his_uses_direct_hid_strategy() {
1761 let mut structure = his_isolated(1);
1762 let config = HydroConfig {
1763 target_ph: Some(7.4),
1764 his_salt_bridge_protonation: false,
1765 his_strategy: HisStrategy::DirectHID,
1766 ..HydroConfig::default()
1767 };
1768
1769 add_hydrogens(&mut structure, &config).unwrap();
1770
1771 let res = structure.find_residue("A", 1, None).unwrap();
1772 assert_eq!(res.name, "HID", "DirectHID strategy should produce HID");
1773 }
1774
1775 #[test]
1776 fn his_uses_direct_hie_strategy() {
1777 let mut structure = his_isolated(1);
1778 let config = HydroConfig {
1779 target_ph: Some(7.4),
1780 his_salt_bridge_protonation: false,
1781 his_strategy: HisStrategy::DirectHIE,
1782 ..HydroConfig::default()
1783 };
1784
1785 add_hydrogens(&mut structure, &config).unwrap();
1786
1787 let res = structure.find_residue("A", 1, None).unwrap();
1788 assert_eq!(res.name, "HIE", "DirectHIE strategy should produce HIE");
1789 }
1790
1791 #[test]
1792 fn his_uses_random_strategy_produces_valid_tautomer() {
1793 let mut structure = his_isolated(1);
1794 let config = HydroConfig {
1795 target_ph: Some(7.4),
1796 his_salt_bridge_protonation: false,
1797 his_strategy: HisStrategy::Random,
1798 ..HydroConfig::default()
1799 };
1800
1801 add_hydrogens(&mut structure, &config).unwrap();
1802
1803 let res = structure.find_residue("A", 1, None).unwrap();
1804 assert!(
1805 res.name == "HID" || res.name == "HIE",
1806 "random strategy should produce either HID or HIE, got {}",
1807 res.name
1808 );
1809 }
1810
1811 #[test]
1812 fn his_uses_hb_network_strategy_defaults_to_hie_without_neighbors() {
1813 let mut structure = his_isolated(1);
1814 let config = HydroConfig {
1815 target_ph: Some(7.4),
1816 his_salt_bridge_protonation: false,
1817 his_strategy: HisStrategy::HbNetwork,
1818 ..HydroConfig::default()
1819 };
1820
1821 add_hydrogens(&mut structure, &config).unwrap();
1822
1823 let res = structure.find_residue("A", 1, None).unwrap();
1824 assert!(
1825 res.name == "HID" || res.name == "HIE",
1826 "HbNetwork strategy should produce HID or HIE"
1827 );
1828 }
1829
1830 #[test]
1831 fn his_preserves_hid_name_without_ph_and_no_salt_bridge() {
1832 let mut residue = residue_from_template("HID", StandardResidue::HIS, 1);
1833 residue.name = "HID".into();
1834 let mut structure = structure_with_residue(residue);
1835 let config = HydroConfig {
1836 target_ph: None,
1837 his_salt_bridge_protonation: false,
1838 ..HydroConfig::default()
1839 };
1840
1841 add_hydrogens(&mut structure, &config).unwrap();
1842
1843 let res = structure.find_residue("A", 1, None).unwrap();
1844 assert_eq!(
1845 res.name, "HID",
1846 "HID should be preserved without pH and no salt bridge"
1847 );
1848 }
1849
1850 #[test]
1851 fn his_preserves_hie_name_without_ph_and_no_salt_bridge() {
1852 let mut residue = residue_from_template("HIE", StandardResidue::HIS, 1);
1853 residue.name = "HIE".into();
1854 let mut structure = structure_with_residue(residue);
1855 let config = HydroConfig {
1856 target_ph: None,
1857 his_salt_bridge_protonation: false,
1858 ..HydroConfig::default()
1859 };
1860
1861 add_hydrogens(&mut structure, &config).unwrap();
1862
1863 let res = structure.find_residue("A", 1, None).unwrap();
1864 assert_eq!(
1865 res.name, "HIE",
1866 "HIE should be preserved without pH and no salt bridge"
1867 );
1868 }
1869
1870 #[test]
1871 fn his_preserves_hip_name_without_ph_and_no_salt_bridge() {
1872 let mut residue = residue_from_template("HIP", StandardResidue::HIS, 1);
1873 residue.name = "HIP".into();
1874 let mut structure = structure_with_residue(residue);
1875 let config = HydroConfig {
1876 target_ph: None,
1877 his_salt_bridge_protonation: false,
1878 ..HydroConfig::default()
1879 };
1880
1881 add_hydrogens(&mut structure, &config).unwrap();
1882
1883 let res = structure.find_residue("A", 1, None).unwrap();
1884 assert_eq!(
1885 res.name, "HIP",
1886 "HIP should be preserved without pH and no salt bridge"
1887 );
1888 }
1889
1890 #[test]
1891 fn his_preserves_name_without_ph_when_no_salt_bridge_found() {
1892 let mut residue = residue_from_template("HID", StandardResidue::HIS, 1);
1893 residue.name = "HID".into();
1894 let mut structure = structure_with_residue(residue);
1895 let config = HydroConfig {
1896 target_ph: None,
1897 his_salt_bridge_protonation: true,
1898 ..HydroConfig::default()
1899 };
1900
1901 add_hydrogens(&mut structure, &config).unwrap();
1902
1903 let res = structure.find_residue("A", 1, None).unwrap();
1904 assert_eq!(
1905 res.name, "HID",
1906 "HID should be preserved when salt bridge not found"
1907 );
1908 }
1909
1910 #[test]
1911 fn his_acidic_ph_overrides_salt_bridge_check() {
1912 let mut structure = his_near_asp(1, 2, 3.5);
1913 let config = HydroConfig {
1914 target_ph: Some(5.0),
1915 his_salt_bridge_protonation: true,
1916 ..HydroConfig::default()
1917 };
1918
1919 add_hydrogens(&mut structure, &config).unwrap();
1920
1921 let his = structure.find_residue("A", 1, None).unwrap();
1922 assert_eq!(
1923 his.name, "HIP",
1924 "acidic pH should result in HIP (priority 1)"
1925 );
1926 }
1927
1928 #[test]
1929 fn his_salt_bridge_overrides_strategy_selection() {
1930 let mut structure = his_near_asp(1, 2, 3.5);
1931 let config = HydroConfig {
1932 target_ph: Some(7.4),
1933 his_salt_bridge_protonation: true,
1934 his_strategy: HisStrategy::DirectHIE,
1935 ..HydroConfig::default()
1936 };
1937
1938 add_hydrogens(&mut structure, &config).unwrap();
1939
1940 let his = structure.find_residue("A", 1, None).unwrap();
1941 assert_eq!(
1942 his.name, "HIP",
1943 "salt bridge should override strategy (priority 3 > 5)"
1944 );
1945 }
1946
1947 #[test]
1948 fn his_strategy_applies_only_after_salt_bridge_miss() {
1949 let mut structure = his_near_asp(1, 2, 10.0);
1950 let config = HydroConfig {
1951 target_ph: Some(7.4),
1952 his_salt_bridge_protonation: true,
1953 his_strategy: HisStrategy::DirectHID,
1954 ..HydroConfig::default()
1955 };
1956
1957 add_hydrogens(&mut structure, &config).unwrap();
1958
1959 let his = structure.find_residue("A", 1, None).unwrap();
1960 assert_eq!(
1961 his.name, "HID",
1962 "strategy should apply when salt bridge not found"
1963 );
1964 }
1965
1966 #[test]
1967 fn add_hydrogens_populates_internal_lysine_side_chain() {
1968 let mut residue = residue_from_template("LYS", StandardResidue::LYS, 10);
1969 residue.add_atom(Atom::new("FAKE", Element::H, Point::origin()));
1970 let mut structure = structure_with_residue(residue);
1971
1972 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
1973
1974 let residue = structure.find_residue("A", 10, None).unwrap();
1975 assert!(residue.has_atom("HZ1"));
1976 assert!(residue.has_atom("HZ2"));
1977 assert!(residue.has_atom("HZ3"));
1978 assert!(
1979 !residue.has_atom("FAKE"),
1980 "existing H should be removed by default"
1981 );
1982 }
1983
1984 #[test]
1985 fn add_hydrogens_keeps_existing_h_when_configured() {
1986 let mut residue = residue_from_template("ALA", StandardResidue::ALA, 1);
1987 residue.add_atom(Atom::new("HX", Element::H, Point::origin()));
1988 let mut structure = structure_with_residue(residue);
1989 let config = HydroConfig {
1990 remove_existing_h: false,
1991 ..HydroConfig::default()
1992 };
1993
1994 add_hydrogens(&mut structure, &config).unwrap();
1995
1996 let residue = structure.find_residue("A", 1, None).unwrap();
1997 assert!(
1998 residue.has_atom("HX"),
1999 "existing H should be kept when configured"
2000 );
2001 }
2002
2003 #[test]
2004 fn construct_hydrogens_errors_when_anchor_missing() {
2005 let template = db::get_template("ALA").expect("template ALA");
2006 let mut residue = Residue::new(
2007 20,
2008 None,
2009 "ALA",
2010 Some(StandardResidue::ALA),
2011 ResidueCategory::Standard,
2012 );
2013 residue.position = ResiduePosition::Internal;
2014
2015 let (name, element, pos) = template.heavy_atoms().next().unwrap();
2016 residue.add_atom(Atom::new(name, element, pos));
2017
2018 let err = construct_hydrogens_for_residue(&mut residue, &HydroConfig::default())
2019 .expect_err("should fail");
2020 assert!(matches!(err, Error::IncompleteResidueForHydro { .. }));
2021 }
2022
2023 #[test]
2024 fn close_cysteines_are_relabeled_to_cyx() {
2025 let cys1 = residue_from_template("CYS", StandardResidue::CYS, 30);
2026 let mut cys2 = residue_from_template("CYS", StandardResidue::CYS, 31);
2027
2028 let sg1 = cys1.atom("SG").expect("SG in cys1").pos;
2029 let sg2 = cys2.atom("SG").expect("SG in cys2").pos;
2030 let desired = sg1 + Vector3::new(0.5, 0.0, 0.0);
2031 let offset = desired - sg2;
2032 for atom in cys2.iter_atoms_mut() {
2033 atom.translate_by(&offset);
2034 }
2035
2036 let mut structure = structure_with_residues(vec![cys1, cys2]);
2037 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2038
2039 let res1 = structure.find_residue("A", 30, None).unwrap();
2040 let res2 = structure.find_residue("A", 31, None).unwrap();
2041 assert_eq!(res1.name, "CYX");
2042 assert_eq!(res2.name, "CYX");
2043 }
2044
2045 #[test]
2046 fn close_cysteines_skip_hg_hydrogen() {
2047 let cys1 = residue_from_template("CYS", StandardResidue::CYS, 30);
2048 let mut cys2 = residue_from_template("CYS", StandardResidue::CYS, 31);
2049
2050 let sg1 = cys1.atom("SG").expect("SG in cys1").pos;
2051 let sg2 = cys2.atom("SG").expect("SG in cys2").pos;
2052 let desired = sg1 + Vector3::new(0.5, 0.0, 0.0);
2053 let offset = desired - sg2;
2054 for atom in cys2.iter_atoms_mut() {
2055 atom.translate_by(&offset);
2056 }
2057
2058 let mut structure = structure_with_residues(vec![cys1, cys2]);
2059 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2060
2061 let res1 = structure.find_residue("A", 30, None).unwrap();
2062 let res2 = structure.find_residue("A", 31, None).unwrap();
2063 assert!(!res1.has_atom("HG"), "CYX should not have HG");
2064 assert!(!res2.has_atom("HG"), "CYX should not have HG");
2065 }
2066
2067 #[test]
2068 fn distant_cysteines_remain_unchanged() {
2069 let cys1 = residue_from_template("CYS", StandardResidue::CYS, 30);
2070 let mut cys2 = residue_from_template("CYS", StandardResidue::CYS, 31);
2071
2072 let offset = Vector3::new(20.0, 0.0, 0.0);
2073 for atom in cys2.iter_atoms_mut() {
2074 atom.translate_by(&offset);
2075 }
2076
2077 let mut structure = structure_with_residues(vec![cys1, cys2]);
2078 let config = HydroConfig {
2079 target_ph: Some(7.4),
2080 ..HydroConfig::default()
2081 };
2082 add_hydrogens(&mut structure, &config).unwrap();
2083
2084 let res1 = structure.find_residue("A", 30, None).unwrap();
2085 let res2 = structure.find_residue("A", 31, None).unwrap();
2086 assert_eq!(res1.name, "CYS", "distant CYS should remain CYS");
2087 assert_eq!(res2.name, "CYS", "distant CYS should remain CYS");
2088 }
2089
2090 #[test]
2091 fn n_terminal_defaults_to_protonated_without_ph() {
2092 let residue = n_terminal_residue(40);
2093 let mut structure = structure_with_residue(residue);
2094
2095 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2096
2097 let residue = structure.find_residue("A", 40, None).unwrap();
2098 assert!(residue.has_atom("H1"));
2099 assert!(residue.has_atom("H2"));
2100 assert!(
2101 residue.has_atom("H3"),
2102 "N-term should have 3 H at default pH 7.0"
2103 );
2104 }
2105
2106 #[test]
2107 fn n_terminal_remains_protonated_below_pka() {
2108 let residue = n_terminal_residue(40);
2109 let mut structure = structure_with_residue(residue);
2110 let config = HydroConfig {
2111 target_ph: Some(7.0),
2112 ..HydroConfig::default()
2113 };
2114
2115 add_hydrogens(&mut structure, &config).unwrap();
2116
2117 let residue = structure.find_residue("A", 40, None).unwrap();
2118 assert!(residue.has_atom("H1"));
2119 assert!(residue.has_atom("H2"));
2120 assert!(
2121 residue.has_atom("H3"),
2122 "N-term should have 3 H below pKa 8.0"
2123 );
2124 }
2125
2126 #[test]
2127 fn n_terminal_deprotonates_above_pka() {
2128 let residue = n_terminal_residue(41);
2129 let mut structure = structure_with_residue(residue);
2130 let config = HydroConfig {
2131 target_ph: Some(9.0),
2132 ..HydroConfig::default()
2133 };
2134
2135 add_hydrogens(&mut structure, &config).unwrap();
2136
2137 let residue = structure.find_residue("A", 41, None).unwrap();
2138 assert!(residue.has_atom("H1"));
2139 assert!(residue.has_atom("H2"));
2140 assert!(
2141 !residue.has_atom("H3"),
2142 "N-term should have only 2 H above pKa 8.0"
2143 );
2144 }
2145
2146 #[test]
2147 fn n_terminal_h_has_tetrahedral_bond_lengths() {
2148 let residue = n_terminal_residue(98);
2149 let mut structure = structure_with_residue(residue);
2150
2151 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2152
2153 let residue = structure.find_residue("A", 98, None).unwrap();
2154 let n = residue.atom("N").expect("N").pos;
2155 let h1 = residue.atom("H1").expect("H1").pos;
2156 let h2 = residue.atom("H2").expect("H2").pos;
2157 let h3 = residue.atom("H3").expect("H3").pos;
2158
2159 let n_h1_dist = distance(n, h1);
2160 let n_h2_dist = distance(n, h2);
2161 let n_h3_dist = distance(n, h3);
2162 assert!(
2163 (n_h1_dist - NH_BOND_LENGTH).abs() < 0.1,
2164 "N-H1 distance {n_h1_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2165 );
2166 assert!(
2167 (n_h2_dist - NH_BOND_LENGTH).abs() < 0.1,
2168 "N-H2 distance {n_h2_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2169 );
2170 assert!(
2171 (n_h3_dist - NH_BOND_LENGTH).abs() < 0.1,
2172 "N-H3 distance {n_h3_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2173 );
2174 }
2175
2176 #[test]
2177 fn n_terminal_h_has_tetrahedral_bond_angles() {
2178 let residue = n_terminal_residue(98);
2179 let mut structure = structure_with_residue(residue);
2180
2181 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2182
2183 let residue = structure.find_residue("A", 98, None).unwrap();
2184 let n = residue.atom("N").expect("N").pos;
2185 let ca = residue.atom("CA").expect("CA").pos;
2186 let h1 = residue.atom("H1").expect("H1").pos;
2187 let h2 = residue.atom("H2").expect("H2").pos;
2188 let h3 = residue.atom("H3").expect("H3").pos;
2189
2190 let ca_n_h1_angle = angle_deg(ca, n, h1);
2191 let ca_n_h2_angle = angle_deg(ca, n, h2);
2192 let ca_n_h3_angle = angle_deg(ca, n, h3);
2193 let h1_n_h2_angle = angle_deg(h1, n, h2);
2194 let h2_n_h3_angle = angle_deg(h2, n, h3);
2195 let h1_n_h3_angle = angle_deg(h1, n, h3);
2196
2197 assert!(
2198 (ca_n_h1_angle - SP3_ANGLE).abs() < 5.0,
2199 "CA-N-H1 angle {ca_n_h1_angle:.1}° should be ~{SP3_ANGLE}°"
2200 );
2201 assert!(
2202 (ca_n_h2_angle - SP3_ANGLE).abs() < 5.0,
2203 "CA-N-H2 angle {ca_n_h2_angle:.1}° should be ~{SP3_ANGLE}°"
2204 );
2205 assert!(
2206 (ca_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2207 "CA-N-H3 angle {ca_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2208 );
2209 assert!(
2210 (h1_n_h2_angle - SP3_ANGLE).abs() < 5.0,
2211 "H1-N-H2 angle {h1_n_h2_angle:.1}° should be ~{SP3_ANGLE}°"
2212 );
2213 assert!(
2214 (h2_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2215 "H2-N-H3 angle {h2_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2216 );
2217 assert!(
2218 (h1_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2219 "H1-N-H3 angle {h1_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2220 );
2221 }
2222
2223 #[test]
2224 fn c_terminal_defaults_to_deprotonated_without_ph() {
2225 let residue = c_terminal_residue(51);
2226 let mut structure = structure_with_residue(residue);
2227
2228 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2229
2230 let residue = structure.find_residue("A", 51, None).unwrap();
2231 assert!(
2232 !residue.has_atom("HOXT"),
2233 "C-term should be deprotonated at default pH 7.0"
2234 );
2235 }
2236
2237 #[test]
2238 fn c_terminal_protonates_under_acidic_ph() {
2239 let residue = c_terminal_residue(50);
2240 let mut structure = structure_with_residue(residue);
2241 let config = HydroConfig {
2242 target_ph: Some(2.5),
2243 ..HydroConfig::default()
2244 };
2245
2246 add_hydrogens(&mut structure, &config).unwrap();
2247
2248 let residue = structure.find_residue("A", 50, None).unwrap();
2249 assert!(
2250 residue.has_atom("HOXT"),
2251 "C-term should be protonated below pKa 3.1"
2252 );
2253 }
2254
2255 #[test]
2256 fn c_terminal_remains_deprotonated_at_physiological_ph() {
2257 let residue = c_terminal_residue(51);
2258 let mut structure = structure_with_residue(residue);
2259 let config = HydroConfig {
2260 target_ph: Some(7.4),
2261 ..HydroConfig::default()
2262 };
2263
2264 add_hydrogens(&mut structure, &config).unwrap();
2265
2266 let residue = structure.find_residue("A", 51, None).unwrap();
2267 assert!(
2268 !residue.has_atom("HOXT"),
2269 "C-term should be deprotonated at pH 7.4"
2270 );
2271 }
2272
2273 #[test]
2274 fn c_terminal_hoxt_has_tetrahedral_bond_length() {
2275 let residue = c_terminal_residue(99);
2276 let mut structure = structure_with_residue(residue);
2277 let config = HydroConfig {
2278 target_ph: Some(2.0),
2279 ..HydroConfig::default()
2280 };
2281
2282 add_hydrogens(&mut structure, &config).unwrap();
2283
2284 let residue = structure.find_residue("A", 99, None).unwrap();
2285 let oxt = residue.atom("OXT").expect("OXT").pos;
2286 let hoxt = residue.atom("HOXT").expect("HOXT").pos;
2287
2288 let oxt_hoxt_dist = distance(oxt, hoxt);
2289 assert!(
2290 (oxt_hoxt_dist - COOH_BOND_LENGTH).abs() < 0.1,
2291 "OXT-HOXT distance {oxt_hoxt_dist:.3} should be ~{COOH_BOND_LENGTH} Å"
2292 );
2293 }
2294
2295 #[test]
2296 fn c_terminal_hoxt_has_tetrahedral_bond_angle() {
2297 let residue = c_terminal_residue(99);
2298 let mut structure = structure_with_residue(residue);
2299 let config = HydroConfig {
2300 target_ph: Some(2.0),
2301 ..HydroConfig::default()
2302 };
2303
2304 add_hydrogens(&mut structure, &config).unwrap();
2305
2306 let residue = structure.find_residue("A", 99, None).unwrap();
2307 let c = residue.atom("C").expect("C").pos;
2308 let oxt = residue.atom("OXT").expect("OXT").pos;
2309 let hoxt = residue.atom("HOXT").expect("HOXT").pos;
2310
2311 let c_oxt_hoxt_angle = angle_deg(c, oxt, hoxt);
2312 assert!(
2313 (c_oxt_hoxt_angle - SP3_ANGLE).abs() < 5.0,
2314 "C-OXT-HOXT angle {c_oxt_hoxt_angle:.1}° should be ~{SP3_ANGLE}°"
2315 );
2316 }
2317
2318 #[test]
2319 fn five_prime_phosphate_deprotonated_at_physiological_ph() {
2320 let residue = five_prime_residue_with_phosphate(60);
2321 let mut structure = structure_with_residue(residue);
2322
2323 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2324
2325 let residue = structure.find_residue("A", 60, None).unwrap();
2326 assert!(residue.has_atom("OP3"), "OP3 should remain");
2327 assert!(
2328 !residue.has_atom("HOP3"),
2329 "HOP3 should not exist at neutral pH"
2330 );
2331 assert!(
2332 !residue.has_atom("HOP2"),
2333 "HOP2 should not exist at neutral pH"
2334 );
2335 }
2336
2337 #[test]
2338 fn five_prime_phosphate_protonated_below_pka() {
2339 let residue = five_prime_residue_with_phosphate(61);
2340 let mut structure = structure_with_residue(residue);
2341 let config = HydroConfig {
2342 target_ph: Some(5.5),
2343 ..HydroConfig::default()
2344 };
2345
2346 add_hydrogens(&mut structure, &config).unwrap();
2347
2348 let residue = structure.find_residue("A", 61, None).unwrap();
2349 assert!(residue.has_atom("OP3"), "OP3 should remain");
2350 assert!(
2351 residue.has_atom("HOP3"),
2352 "HOP3 should be added below pKa 6.5"
2353 );
2354 }
2355
2356 #[test]
2357 fn five_prime_without_phosphate_gets_ho5() {
2358 let residue = five_prime_residue_without_phosphate(62);
2359 let mut structure = structure_with_residue(residue);
2360
2361 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2362
2363 let residue = structure.find_residue("A", 62, None).unwrap();
2364 assert!(
2365 residue.has_atom("HO5'"),
2366 "HO5' should be added for 5'-OH terminus"
2367 );
2368 assert!(!residue.has_atom("P"), "phosphorus should not exist");
2369 }
2370
2371 #[test]
2372 fn five_prime_ho5_has_tetrahedral_geometry() {
2373 let residue = five_prime_residue_without_phosphate(80);
2374 let mut structure = structure_with_residue(residue);
2375
2376 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2377
2378 let residue = structure.find_residue("A", 80, None).unwrap();
2379 let c5 = residue.atom("C5'").expect("C5'").pos;
2380 let o5 = residue.atom("O5'").expect("O5'").pos;
2381 let ho5 = residue.atom("HO5'").expect("HO5'").pos;
2382
2383 let o5_ho5_dist = distance(o5, ho5);
2384 assert!(
2385 (o5_ho5_dist - OH_BOND_LENGTH).abs() < 0.1,
2386 "O5'-HO5' distance {o5_ho5_dist:.3} should be ~{OH_BOND_LENGTH} Å"
2387 );
2388
2389 let c5_o5_ho5_angle = angle_deg(c5, o5, ho5);
2390 assert!(
2391 (c5_o5_ho5_angle - SP3_ANGLE).abs() < 5.0,
2392 "C5'-O5'-HO5' angle {c5_o5_ho5_angle:.1}° should be ~{SP3_ANGLE}°"
2393 );
2394 }
2395
2396 #[test]
2397 fn five_prime_phosphate_hop3_has_tetrahedral_geometry() {
2398 let residue = five_prime_residue_with_phosphate(82);
2399 let mut structure = structure_with_residue(residue);
2400 let config = HydroConfig {
2401 target_ph: Some(5.5),
2402 ..HydroConfig::default()
2403 };
2404
2405 add_hydrogens(&mut structure, &config).unwrap();
2406
2407 let residue = structure.find_residue("A", 82, None).unwrap();
2408 let p = residue.atom("P").expect("P").pos;
2409 let op3 = residue.atom("OP3").expect("OP3").pos;
2410 let hop3 = residue.atom("HOP3").expect("HOP3").pos;
2411
2412 let op3_hop3_dist = distance(op3, hop3);
2413 assert!(
2414 (op3_hop3_dist - OH_BOND_LENGTH).abs() < 0.1,
2415 "OP3-HOP3 distance {op3_hop3_dist:.3} should be ~{OH_BOND_LENGTH} Å"
2416 );
2417
2418 let p_op3_hop3_angle = angle_deg(p, op3, hop3);
2419 assert!(
2420 (p_op3_hop3_angle - SP3_ANGLE).abs() < 5.0,
2421 "P-OP3-HOP3 angle {p_op3_hop3_angle:.1}° should be ~{SP3_ANGLE}°"
2422 );
2423 }
2424
2425 #[test]
2426 fn three_prime_nucleic_gets_ho3() {
2427 let residue = three_prime_residue(70);
2428 let mut structure = structure_with_residue(residue);
2429
2430 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2431
2432 let residue = structure.find_residue("A", 70, None).unwrap();
2433 assert!(
2434 residue.has_atom("HO3'"),
2435 "HO3' should be added for 3' terminal"
2436 );
2437 }
2438
2439 #[test]
2440 fn three_prime_ho3_has_tetrahedral_geometry() {
2441 let residue = three_prime_residue(81);
2442 let mut structure = structure_with_residue(residue);
2443
2444 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2445
2446 let residue = structure.find_residue("A", 81, None).unwrap();
2447 let c3 = residue.atom("C3'").expect("C3'").pos;
2448 let o3 = residue.atom("O3'").expect("O3'").pos;
2449 let ho3 = residue.atom("HO3'").expect("HO3'").pos;
2450
2451 let o3_ho3_dist = distance(o3, ho3);
2452 assert!(
2453 (o3_ho3_dist - OH_BOND_LENGTH).abs() < 0.1,
2454 "O3'-HO3' distance {o3_ho3_dist:.3} should be ~{OH_BOND_LENGTH} Å"
2455 );
2456
2457 let c3_o3_ho3_angle = angle_deg(c3, o3, ho3);
2458 assert!(
2459 (c3_o3_ho3_angle - SP3_ANGLE).abs() < 5.0,
2460 "C3'-O3'-HO3' angle {c3_o3_ho3_angle:.1}° should be ~{SP3_ANGLE}°"
2461 );
2462 }
2463
2464 #[test]
2465 fn hydro_config_defaults_to_no_ph() {
2466 let config = HydroConfig::default();
2467 assert!(config.target_ph.is_none(), "default should have no pH");
2468 }
2469
2470 #[test]
2471 fn hydro_config_defaults_to_remove_existing_h() {
2472 let config = HydroConfig::default();
2473 assert!(config.remove_existing_h, "default should remove existing H");
2474 }
2475
2476 #[test]
2477 fn hydro_config_defaults_to_hb_network_strategy() {
2478 let config = HydroConfig::default();
2479 assert_eq!(
2480 config.his_strategy,
2481 HisStrategy::HbNetwork,
2482 "default should use HbNetwork"
2483 );
2484 }
2485
2486 #[test]
2487 fn hydro_config_defaults_to_salt_bridge_enabled() {
2488 let config = HydroConfig::default();
2489 assert!(
2490 config.his_salt_bridge_protonation,
2491 "default should enable salt bridge detection"
2492 );
2493 }
2494
2495 #[test]
2496 fn effective_terminal_ph_returns_target_when_set() {
2497 assert_eq!(effective_terminal_ph(Some(5.5)), 5.5);
2498 assert_eq!(effective_terminal_ph(Some(9.0)), 9.0);
2499 }
2500
2501 #[test]
2502 fn effective_terminal_ph_returns_default_when_unset() {
2503 assert_eq!(effective_terminal_ph(None), DEFAULT_TERMINAL_PH);
2504 }
2505
2506 #[test]
2507 fn c_terminus_is_deprotonated_at_and_above_pka() {
2508 assert!(c_terminus_is_deprotonated(Some(7.4)));
2509 assert!(c_terminus_is_deprotonated(Some(4.0)));
2510 assert!(c_terminus_is_deprotonated(Some(C_TERM_PKA)));
2511 assert!(c_terminus_is_deprotonated(None));
2512 }
2513
2514 #[test]
2515 fn c_terminus_is_protonated_below_pka() {
2516 assert!(!c_terminus_is_deprotonated(Some(2.0)));
2517 assert!(!c_terminus_is_deprotonated(Some(3.0)));
2518 assert!(!c_terminus_is_deprotonated(Some(C_TERM_PKA - 0.1)));
2519 }
2520
2521 #[test]
2522 fn n_terminus_is_protonated_at_and_below_pka() {
2523 assert!(n_term_is_protonated(Some(7.0)));
2524 assert!(n_term_is_protonated(Some(N_TERM_PKA)));
2525 assert!(n_term_is_protonated(None));
2526 }
2527
2528 #[test]
2529 fn n_terminus_is_deprotonated_above_pka() {
2530 assert!(!n_term_is_protonated(Some(9.0)));
2531 assert!(!n_term_is_protonated(Some(N_TERM_PKA + 0.1)));
2532 }
2533
2534 #[test]
2535 fn c_term_protonation_boundary_is_exclusive() {
2536 assert!(c_terminus_is_deprotonated(Some(C_TERM_PKA)));
2537 assert!(!c_term_is_protonated(Some(C_TERM_PKA)));
2538 }
2539
2540 #[test]
2541 fn acceptor_grid_includes_nitrogen_atoms() {
2542 let residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2543 let structure = structure_with_residue(residue);
2544
2545 let grid = build_acceptor_grid(&structure);
2546 let ala = structure.find_residue("A", 1, None).unwrap();
2547 let n = ala.atom("N").unwrap().pos;
2548
2549 let neighbors: Vec<_> = grid.neighbors(&n, 0.1).exact().collect();
2550 assert!(!neighbors.is_empty(), "N should be in acceptor grid");
2551 }
2552
2553 #[test]
2554 fn acceptor_grid_includes_oxygen_atoms() {
2555 let residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2556 let structure = structure_with_residue(residue);
2557
2558 let grid = build_acceptor_grid(&structure);
2559 let ala = structure.find_residue("A", 1, None).unwrap();
2560 let o = ala.atom("O").unwrap().pos;
2561
2562 let neighbors: Vec<_> = grid.neighbors(&o, 0.1).exact().collect();
2563 assert!(!neighbors.is_empty(), "O should be in acceptor grid");
2564 }
2565
2566 #[test]
2567 fn acceptor_grid_excludes_carbon_atoms() {
2568 let residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2569 let structure = structure_with_residue(residue);
2570
2571 let grid = build_acceptor_grid(&structure);
2572 let ala = structure.find_residue("A", 1, None).unwrap();
2573 let ca = ala.atom("CA").unwrap().pos;
2574
2575 let neighbors: Vec<_> = grid.neighbors(&ca, 0.1).exact().collect();
2576 assert!(neighbors.is_empty(), "CA should NOT be in acceptor grid");
2577 }
2578
2579 #[test]
2580 fn full_pipeline_preserves_user_protonation_without_ph() {
2581 let mut his = residue_from_template("HID", StandardResidue::HIS, 1);
2582 his.name = "HID".into();
2583 let mut ash = residue_from_template("ASP", StandardResidue::ASP, 2);
2584 ash.name = "ASH".into();
2585 let mut structure = structure_with_residues(vec![his, ash]);
2586
2587 let config = HydroConfig {
2588 target_ph: None,
2589 his_salt_bridge_protonation: false,
2590 ..HydroConfig::default()
2591 };
2592
2593 add_hydrogens(&mut structure, &config).unwrap();
2594
2595 let his = structure.find_residue("A", 1, None).unwrap();
2596 let ash = structure.find_residue("A", 2, None).unwrap();
2597 assert_eq!(his.name, "HID", "user-specified HID should be preserved");
2598 assert_eq!(ash.name, "ASH", "user-specified ASH should be preserved");
2599 }
2600
2601 #[test]
2602 fn full_pipeline_applies_all_pka_rules_with_ph() {
2603 let asp = residue_from_template("ASP", StandardResidue::ASP, 1);
2604 let glu = residue_from_template("GLU", StandardResidue::GLU, 2);
2605 let lys = residue_from_template("LYS", StandardResidue::LYS, 3);
2606 let mut structure = structure_with_residues(vec![asp, glu, lys]);
2607
2608 let config = HydroConfig {
2609 target_ph: Some(7.4),
2610 ..HydroConfig::default()
2611 };
2612
2613 add_hydrogens(&mut structure, &config).unwrap();
2614
2615 let asp = structure.find_residue("A", 1, None).unwrap();
2616 let glu = structure.find_residue("A", 2, None).unwrap();
2617 let lys = structure.find_residue("A", 3, None).unwrap();
2618 assert_eq!(asp.name, "ASP", "ASP should remain at pH 7.4");
2619 assert_eq!(glu.name, "GLU", "GLU should remain at pH 7.4");
2620 assert_eq!(lys.name, "LYS", "LYS should remain at pH 7.4");
2621 }
2622
2623 #[test]
2624 fn full_pipeline_detects_salt_bridge_and_converts_his() {
2625 let mut structure = his_near_asp(1, 2, 3.5);
2626 let config = HydroConfig {
2627 target_ph: Some(7.4),
2628 his_salt_bridge_protonation: true,
2629 ..HydroConfig::default()
2630 };
2631
2632 add_hydrogens(&mut structure, &config).unwrap();
2633
2634 let his = structure.find_residue("A", 1, None).unwrap();
2635 let asp = structure.find_residue("A", 2, None).unwrap();
2636 assert_eq!(his.name, "HIP", "HIS should become HIP via salt bridge");
2637 assert_eq!(asp.name, "ASP", "ASP should remain deprotonated");
2638 }
2639
2640 #[test]
2641 fn full_pipeline_with_all_options_disabled() {
2642 let mut his = residue_from_template("HID", StandardResidue::HIS, 1);
2643 his.name = "HID".into();
2644 let mut structure = structure_with_residue(his);
2645
2646 let config = HydroConfig {
2647 target_ph: None,
2648 remove_existing_h: false,
2649 his_salt_bridge_protonation: false,
2650 his_strategy: HisStrategy::DirectHIE,
2651 };
2652
2653 add_hydrogens(&mut structure, &config).unwrap();
2654
2655 let his = structure.find_residue("A", 1, None).unwrap();
2656 assert_eq!(
2657 his.name, "HID",
2658 "HID should be preserved with all options disabled"
2659 );
2660 }
2661}