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 let Some(ph) = config.target_ph {
138 apply_non_his_protonation(structure, ph);
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 if residue.standard_name == Some(StandardResidue::PRO) {
736 let cd_pos = residue
737 .atom("CD")
738 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "CD"))?
739 .pos;
740
741 let v_ca = (ca_pos - n_pos).normalize();
742 let v_cd = (cd_pos - n_pos).normalize();
743 let v_mid = -(v_ca + v_cd).normalize();
744 let v_perp = v_ca.cross(&v_cd).normalize();
745
746 let half_spread = (1.0_f64 / 3.0_f64.sqrt()).acos();
747 let h2_dir = v_mid * half_spread.cos() + v_perp * half_spread.sin();
748 residue.add_atom(Atom::new("H2", Element::H, n_pos + h2_dir * NH_BOND_LENGTH));
749
750 if protonated {
751 let h3_dir = v_mid * half_spread.cos() - v_perp * half_spread.sin();
752 residue.add_atom(Atom::new("H3", Element::H, n_pos + h3_dir * NH_BOND_LENGTH));
753 }
754 } else {
755 let target_count = if protonated { 3 } else { 2 };
756 let (x, y, z) = build_sp3_frame(n_pos, ca_pos, None);
757
758 let theta = SP3_ANGLE.to_radians();
759 let sin_theta = theta.sin();
760 let cos_theta = theta.cos();
761
762 let phases = [0.0_f64, 120.0, 240.0];
763 let names = ["H1", "H2", "H3"];
764
765 for (idx, phase) in phases.iter().take(target_count).enumerate() {
766 let phi = phase.to_radians();
767 let h_local = Vector3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), -cos_theta);
768 let h_global = x * h_local.x + y * h_local.y + z * h_local.z;
769 residue.add_atom(Atom::new(
770 names[idx],
771 Element::H,
772 n_pos + h_global * NH_BOND_LENGTH,
773 ));
774 }
775 }
776
777 Ok(())
778}
779
780fn construct_c_term_hydrogen(residue: &mut Residue, protonated: bool) -> Result<(), Error> {
782 if !protonated {
783 residue.remove_atom("HOXT");
784 residue.remove_atom("HXT");
785 return Ok(());
786 }
787
788 residue.remove_atom("HXT");
789 if residue.has_atom("HOXT") {
790 return Ok(());
791 }
792
793 let c_pos = residue
794 .atom("C")
795 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "C"))?
796 .pos;
797 let oxt_pos = residue
798 .atom("OXT")
799 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "OXT"))?
800 .pos;
801
802 let c_oxt_dist = (oxt_pos - c_pos).norm();
803 if c_oxt_dist < 1e-6 {
804 return Err(Error::incomplete_for_hydro(
805 &*residue.name,
806 residue.id,
807 "OXT",
808 ));
809 }
810
811 let reference_pos = residue
812 .atom("CA")
813 .or_else(|| residue.atom("O"))
814 .map(|a| a.pos);
815
816 let h_pos = place_hydroxyl_hydrogen(
817 oxt_pos,
818 c_pos,
819 reference_pos,
820 COOH_BOND_LENGTH,
821 SP3_ANGLE,
822 60.0,
823 );
824
825 residue.add_atom(Atom::new("HOXT", Element::H, h_pos));
826 Ok(())
827}
828
829fn construct_3_prime_hydrogen(residue: &mut Residue) -> Result<(), Error> {
831 if residue.has_atom("HO3'") {
832 return Ok(());
833 }
834
835 let o3_pos = residue
836 .atom("O3'")
837 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "O3'"))?
838 .pos;
839 let c3_pos = residue
840 .atom("C3'")
841 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "C3'"))?
842 .pos;
843
844 let reference_pos = residue
845 .atom("C4'")
846 .or_else(|| residue.atom("C2'"))
847 .map(|a| a.pos);
848
849 let h_pos = place_hydroxyl_hydrogen(
850 o3_pos,
851 c3_pos,
852 reference_pos,
853 OH_BOND_LENGTH,
854 SP3_ANGLE,
855 180.0,
856 );
857
858 residue.add_atom(Atom::new("HO3'", Element::H, h_pos));
859 Ok(())
860}
861
862fn construct_5_prime_hydrogen(residue: &mut Residue) -> Result<(), Error> {
864 if residue.has_atom("HO5'") {
865 return Ok(());
866 }
867
868 let o5_pos = residue
869 .atom("O5'")
870 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "O5'"))?
871 .pos;
872 let c5_pos = residue
873 .atom("C5'")
874 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "C5'"))?
875 .pos;
876
877 let reference_pos = residue.atom("C4'").map(|a| a.pos);
878
879 let h_pos = place_hydroxyl_hydrogen(
880 o5_pos,
881 c5_pos,
882 reference_pos,
883 OH_BOND_LENGTH,
884 SP3_ANGLE,
885 180.0,
886 );
887
888 residue.add_atom(Atom::new("HO5'", Element::H, h_pos));
889 Ok(())
890}
891
892fn construct_5_prime_phosphate_hydrogens(
897 residue: &mut Residue,
898 target_ph: Option<f64>,
899) -> Result<(), Error> {
900 let ph = effective_terminal_ph(target_ph);
901
902 if ph >= PHOSPHATE_PKA2 {
903 residue.remove_atom("HOP3");
904 residue.remove_atom("HOP2");
905 return Ok(());
906 }
907
908 if residue.has_atom("HOP3") {
909 return Ok(());
910 }
911
912 let op3_pos = residue
913 .atom("OP3")
914 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "OP3"))?
915 .pos;
916 let p_pos = residue
917 .atom("P")
918 .ok_or_else(|| Error::incomplete_for_hydro(&*residue.name, residue.id, "P"))?
919 .pos;
920
921 let reference_pos = residue
922 .atom("OP1")
923 .or_else(|| residue.atom("OP2"))
924 .map(|a| a.pos);
925
926 let h_pos = place_hydroxyl_hydrogen(
927 op3_pos,
928 p_pos,
929 reference_pos,
930 OH_BOND_LENGTH,
931 SP3_ANGLE,
932 180.0,
933 );
934
935 residue.add_atom(Atom::new("HOP3", Element::H, h_pos));
936 Ok(())
937}
938
939fn reconstruct_geometry(
941 residue: &Residue,
942 target_tmpl_pos: Point,
943 anchor_names: &[&str],
944 rotation_override: Option<Rotation3<f64>>,
945) -> Result<Point, ()> {
946 let template_view = db::get_template(&residue.name).ok_or(())?;
947
948 let mut residue_pts = Vec::new();
949 let mut template_pts = Vec::new();
950
951 for name in anchor_names {
952 let r_atom = residue.atom(name).ok_or(())?;
953 residue_pts.push(r_atom.pos);
954
955 let t_pos = template_view
956 .heavy_atoms()
957 .find(|(n, _, _)| n == name)
958 .map(|(_, _, p)| p)
959 .ok_or(())?;
960 template_pts.push(t_pos);
961 }
962
963 let (mut rot, mut trans) = calculate_transform(&residue_pts, &template_pts).ok_or(())?;
964
965 if let (Some(override_rot), 1) = (rotation_override, anchor_names.len()) {
966 rot = override_rot.into_inner();
967 trans = residue_pts[0].coords - rot * template_pts[0].coords;
968 }
969
970 Ok(rot * target_tmpl_pos + trans)
971}
972
973fn build_sp3_frame(
976 center: Point,
977 attached: Point,
978 reference: Option<Point>,
979) -> (Vector3<f64>, Vector3<f64>, Vector3<f64>) {
980 let z = (center - attached).normalize();
981
982 let ref_vec = reference
983 .map(|r| (r - attached).normalize())
984 .unwrap_or_else(|| {
985 if z.x.abs() < z.y.abs() && z.x.abs() < z.z.abs() {
986 Vector3::x()
987 } else if z.y.abs() < z.z.abs() {
988 Vector3::y()
989 } else {
990 Vector3::z()
991 }
992 });
993
994 let x = (ref_vec - z * z.dot(&ref_vec)).normalize();
995 let y = z.cross(&x);
996
997 (x, y, z)
998}
999
1000fn place_hydroxyl_hydrogen(
1002 o_pos: Point,
1003 attached_pos: Point,
1004 reference_pos: Option<Point>,
1005 bond_length: f64,
1006 bond_angle: f64,
1007 dihedral_offset: f64,
1008) -> Point {
1009 let (x, y, z) = build_sp3_frame(o_pos, attached_pos, reference_pos);
1010
1011 let theta = bond_angle.to_radians();
1012 let phi = dihedral_offset.to_radians();
1013
1014 let sin_theta = theta.sin();
1015 let cos_theta = theta.cos();
1016
1017 let h_local = Vector3::new(sin_theta * phi.cos(), sin_theta * phi.sin(), -cos_theta);
1018 let h_global = x * h_local.x + y * h_local.y + z * h_local.z;
1019
1020 o_pos + h_global * bond_length
1021}
1022
1023fn calculate_transform(r_pts: &[Point], t_pts: &[Point]) -> Option<(Matrix3<f64>, Vector3<f64>)> {
1027 let n = r_pts.len();
1028 if n != t_pts.len() || n == 0 {
1029 return None;
1030 }
1031
1032 let r_center = r_pts.iter().map(|p| p.coords).sum::<Vector3<f64>>() / n as f64;
1033 let t_center = t_pts.iter().map(|p| p.coords).sum::<Vector3<f64>>() / n as f64;
1034
1035 if n == 1 {
1036 return Some((Matrix3::identity(), r_center - t_center));
1037 }
1038
1039 if n == 2 {
1040 let v_r = r_pts[1] - r_pts[0];
1041 let v_t = t_pts[1] - t_pts[0];
1042 let rot = Rotation3::rotation_between(&v_t, &v_r).unwrap_or_else(Rotation3::identity);
1043 let trans = r_center - rot * t_center;
1044 return Some((rot.into_inner(), trans));
1045 }
1046
1047 let mut cov = Matrix3::zeros();
1048 for (p_r, p_t) in r_pts.iter().zip(t_pts.iter()) {
1049 let v_r = p_r.coords - r_center;
1050 let v_t = p_t.coords - t_center;
1051 cov += v_r * v_t.transpose();
1052 }
1053
1054 let svd = cov.svd(true, true);
1055 let u = svd.u?;
1056 let v_t = svd.v_t?;
1057
1058 let mut rot = u * v_t;
1059 if rot.determinant() < 0.0 {
1060 let mut corr = Matrix3::identity();
1061 corr[(2, 2)] = -1.0;
1062 rot = u * corr * v_t;
1063 }
1064
1065 let trans = r_center - rot * t_center;
1066 Some((rot, trans))
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use crate::model::{
1073 atom::Atom,
1074 chain::Chain,
1075 residue::Residue,
1076 types::{Element, Point, ResidueCategory, ResiduePosition, StandardResidue},
1077 };
1078
1079 fn residue_from_template(name: &str, std: StandardResidue, id: i32) -> Residue {
1080 let template = db::get_template(name).unwrap_or_else(|| panic!("missing template {name}"));
1081 let mut residue = Residue::new(id, None, name, Some(std), ResidueCategory::Standard);
1082 residue.position = ResiduePosition::Internal;
1083 for (atom_name, element, pos) in template.heavy_atoms() {
1084 residue.add_atom(Atom::new(atom_name, element, pos));
1085 }
1086 residue
1087 }
1088
1089 fn structure_with_residue(residue: Residue) -> Structure {
1090 let mut chain = Chain::new("A");
1091 chain.add_residue(residue);
1092 let mut structure = Structure::new();
1093 structure.add_chain(chain);
1094 structure
1095 }
1096
1097 fn structure_with_residues(residues: Vec<Residue>) -> Structure {
1098 let mut chain = Chain::new("A");
1099 for residue in residues {
1100 chain.add_residue(residue);
1101 }
1102 let mut structure = Structure::new();
1103 structure.add_chain(chain);
1104 structure
1105 }
1106
1107 fn n_terminal_residue(id: i32) -> Residue {
1108 let mut residue = residue_from_template("ALA", StandardResidue::ALA, id);
1109 residue.position = ResiduePosition::NTerminal;
1110 residue
1111 }
1112
1113 fn n_terminal_pro_residue(id: i32) -> Residue {
1114 let mut residue = residue_from_template("PRO", StandardResidue::PRO, id);
1115 residue.position = ResiduePosition::NTerminal;
1116 residue
1117 }
1118
1119 fn c_terminal_residue(id: i32) -> Residue {
1120 let mut residue = residue_from_template("ALA", StandardResidue::ALA, id);
1121 residue.position = ResiduePosition::CTerminal;
1122 let c_pos = residue.atom("C").expect("C atom").pos;
1123 let o_pos = residue.atom("O").expect("O atom").pos;
1124 let offset = c_pos - o_pos;
1125 let oxt_pos = c_pos + offset;
1126 residue.add_atom(Atom::new("OXT", Element::O, oxt_pos));
1127 residue
1128 }
1129
1130 fn five_prime_residue_with_phosphate(id: i32) -> Residue {
1131 let template = db::get_template("DA").unwrap();
1132 let mut residue = Residue::new(
1133 id,
1134 None,
1135 "DA",
1136 Some(StandardResidue::DA),
1137 ResidueCategory::Standard,
1138 );
1139 residue.position = ResiduePosition::FivePrime;
1140 for (atom_name, element, pos) in template.heavy_atoms() {
1141 residue.add_atom(Atom::new(atom_name, element, pos));
1142 }
1143 let p_pos = residue.atom("P").unwrap().pos;
1144 let op1_pos = residue.atom("OP1").unwrap().pos;
1145 let op2_pos = residue.atom("OP2").unwrap().pos;
1146 let o5_pos = residue.atom("O5'").unwrap().pos;
1147 let centroid = (op1_pos.coords + op2_pos.coords + o5_pos.coords) / 3.0;
1148 let direction = (p_pos.coords - centroid).normalize();
1149 let op3_pos = p_pos + direction * 1.48;
1150 residue.add_atom(Atom::new("OP3", Element::O, op3_pos));
1151 residue
1152 }
1153
1154 fn five_prime_residue_without_phosphate(id: i32) -> Residue {
1155 let template = db::get_template("DA").unwrap();
1156 let mut residue = Residue::new(
1157 id,
1158 None,
1159 "DA",
1160 Some(StandardResidue::DA),
1161 ResidueCategory::Standard,
1162 );
1163 residue.position = ResiduePosition::FivePrime;
1164 for (atom_name, element, pos) in template.heavy_atoms() {
1165 if !matches!(atom_name, "P" | "OP1" | "OP2") {
1166 residue.add_atom(Atom::new(atom_name, element, pos));
1167 }
1168 }
1169 residue
1170 }
1171
1172 fn three_prime_residue(id: i32) -> Residue {
1173 let template = db::get_template("DA").unwrap();
1174 let mut residue = Residue::new(
1175 id,
1176 None,
1177 "DA",
1178 Some(StandardResidue::DA),
1179 ResidueCategory::Standard,
1180 );
1181 residue.position = ResiduePosition::ThreePrime;
1182 for (atom_name, element, pos) in template.heavy_atoms() {
1183 residue.add_atom(Atom::new(atom_name, element, pos));
1184 }
1185 residue
1186 }
1187
1188 fn his_near_asp(his_id: i32, asp_id: i32, distance: f64) -> Structure {
1189 let his = residue_from_template("HID", StandardResidue::HIS, his_id);
1190 let mut asp = residue_from_template("ASP", StandardResidue::ASP, asp_id);
1191
1192 let his_nd1 = his.atom("ND1").expect("ND1").pos;
1193 let asp_od1 = asp.atom("OD1").expect("OD1").pos;
1194 let offset = his_nd1 + Vector3::new(distance, 0.0, 0.0) - asp_od1;
1195 for atom in asp.iter_atoms_mut() {
1196 atom.translate_by(&offset);
1197 }
1198
1199 structure_with_residues(vec![his, asp])
1200 }
1201
1202 fn his_near_glu(his_id: i32, glu_id: i32, distance: f64) -> Structure {
1203 let his = residue_from_template("HID", StandardResidue::HIS, his_id);
1204 let mut glu = residue_from_template("GLU", StandardResidue::GLU, glu_id);
1205
1206 let his_ne2 = his.atom("NE2").expect("NE2").pos;
1207 let glu_oe1 = glu.atom("OE1").expect("OE1").pos;
1208 let offset = his_ne2 + Vector3::new(distance, 0.0, 0.0) - glu_oe1;
1209 for atom in glu.iter_atoms_mut() {
1210 atom.translate_by(&offset);
1211 }
1212
1213 structure_with_residues(vec![his, glu])
1214 }
1215
1216 fn his_near_c_term(his_id: i32, c_term_id: i32, distance: f64) -> Structure {
1217 let his = residue_from_template("HID", StandardResidue::HIS, his_id);
1218 let mut c_term = c_terminal_residue(c_term_id);
1219
1220 let his_nd1 = his.atom("ND1").expect("ND1").pos;
1221 let c_term_oxt = c_term.atom("OXT").expect("OXT").pos;
1222 let offset = his_nd1 + Vector3::new(distance, 0.0, 0.0) - c_term_oxt;
1223 for atom in c_term.iter_atoms_mut() {
1224 atom.translate_by(&offset);
1225 }
1226
1227 structure_with_residues(vec![his, c_term])
1228 }
1229
1230 fn his_isolated(id: i32) -> Structure {
1231 let his = residue_from_template("HID", StandardResidue::HIS, id);
1232 structure_with_residue(his)
1233 }
1234
1235 fn distance(a: Point, b: Point) -> f64 {
1236 (a - b).norm()
1237 }
1238
1239 fn angle_deg(a: Point, center: Point, b: Point) -> f64 {
1240 let v1 = (a - center).normalize();
1241 let v2 = (b - center).normalize();
1242 v1.dot(&v2).clamp(-1.0, 1.0).acos().to_degrees()
1243 }
1244
1245 #[test]
1246 fn asp_protonates_to_ash_below_pka() {
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: Some(2.5),
1251 ..HydroConfig::default()
1252 };
1253
1254 add_hydrogens(&mut structure, &config).unwrap();
1255
1256 let res = structure.find_residue("A", 1, None).unwrap();
1257 assert_eq!(res.name, "ASH", "ASP should become ASH below pKa 3.9");
1258 }
1259
1260 #[test]
1261 fn asp_remains_deprotonated_above_pka() {
1262 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1263 let mut structure = structure_with_residue(residue);
1264 let config = HydroConfig {
1265 target_ph: Some(7.4),
1266 ..HydroConfig::default()
1267 };
1268
1269 add_hydrogens(&mut structure, &config).unwrap();
1270
1271 let res = structure.find_residue("A", 1, None).unwrap();
1272 assert_eq!(res.name, "ASP", "ASP should remain ASP above pKa 3.9");
1273 }
1274
1275 #[test]
1276 fn asp_preserves_original_name_without_ph() {
1277 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1278 let mut structure = structure_with_residue(residue);
1279 let config = HydroConfig {
1280 target_ph: None,
1281 his_salt_bridge_protonation: false,
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, "ASP", "ASP should be preserved without pH");
1289 }
1290
1291 #[test]
1292 fn glu_protonates_to_glh_below_pka() {
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: Some(3.0),
1297 ..HydroConfig::default()
1298 };
1299
1300 add_hydrogens(&mut structure, &config).unwrap();
1301
1302 let res = structure.find_residue("A", 1, None).unwrap();
1303 assert_eq!(res.name, "GLH", "GLU should become GLH below pKa 4.2");
1304 }
1305
1306 #[test]
1307 fn glu_remains_deprotonated_above_pka() {
1308 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1309 let mut structure = structure_with_residue(residue);
1310 let config = HydroConfig {
1311 target_ph: Some(7.4),
1312 ..HydroConfig::default()
1313 };
1314
1315 add_hydrogens(&mut structure, &config).unwrap();
1316
1317 let res = structure.find_residue("A", 1, None).unwrap();
1318 assert_eq!(res.name, "GLU", "GLU should remain GLU above pKa 4.2");
1319 }
1320
1321 #[test]
1322 fn glu_preserves_original_name_without_ph() {
1323 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1324 let mut structure = structure_with_residue(residue);
1325 let config = HydroConfig {
1326 target_ph: None,
1327 his_salt_bridge_protonation: false,
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, "GLU", "GLU should be preserved without pH");
1335 }
1336
1337 #[test]
1338 fn lys_deprotonates_to_lyn_above_pka() {
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: Some(11.0),
1343 ..HydroConfig::default()
1344 };
1345
1346 add_hydrogens(&mut structure, &config).unwrap();
1347
1348 let res = structure.find_residue("A", 1, None).unwrap();
1349 assert_eq!(res.name, "LYN", "LYS should become LYN above pKa 10.5");
1350 }
1351
1352 #[test]
1353 fn lys_remains_protonated_below_pka() {
1354 let residue = residue_from_template("LYS", StandardResidue::LYS, 1);
1355 let mut structure = structure_with_residue(residue);
1356 let config = HydroConfig {
1357 target_ph: Some(7.4),
1358 ..HydroConfig::default()
1359 };
1360
1361 add_hydrogens(&mut structure, &config).unwrap();
1362
1363 let res = structure.find_residue("A", 1, None).unwrap();
1364 assert_eq!(res.name, "LYS", "LYS should remain LYS below pKa 10.5");
1365 }
1366
1367 #[test]
1368 fn lys_preserves_original_name_without_ph() {
1369 let residue = residue_from_template("LYS", StandardResidue::LYS, 1);
1370 let mut structure = structure_with_residue(residue);
1371 let config = HydroConfig {
1372 target_ph: None,
1373 his_salt_bridge_protonation: false,
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, "LYS", "LYS should be preserved without pH");
1381 }
1382
1383 #[test]
1384 fn cys_deprotonates_to_cym_above_pka() {
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: Some(9.0),
1389 ..HydroConfig::default()
1390 };
1391
1392 add_hydrogens(&mut structure, &config).unwrap();
1393
1394 let res = structure.find_residue("A", 1, None).unwrap();
1395 assert_eq!(res.name, "CYM", "CYS should become CYM above pKa 8.3");
1396 }
1397
1398 #[test]
1399 fn cys_remains_protonated_below_pka() {
1400 let residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1401 let mut structure = structure_with_residue(residue);
1402 let config = HydroConfig {
1403 target_ph: Some(7.4),
1404 ..HydroConfig::default()
1405 };
1406
1407 add_hydrogens(&mut structure, &config).unwrap();
1408
1409 let res = structure.find_residue("A", 1, None).unwrap();
1410 assert_eq!(res.name, "CYS", "CYS should remain CYS below pKa 8.3");
1411 }
1412
1413 #[test]
1414 fn cys_preserves_original_name_without_ph() {
1415 let residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1416 let mut structure = structure_with_residue(residue);
1417 let config = HydroConfig {
1418 target_ph: None,
1419 his_salt_bridge_protonation: false,
1420 ..HydroConfig::default()
1421 };
1422
1423 add_hydrogens(&mut structure, &config).unwrap();
1424
1425 let res = structure.find_residue("A", 1, None).unwrap();
1426 assert_eq!(res.name, "CYS", "CYS should be preserved without pH");
1427 }
1428
1429 #[test]
1430 fn cyx_is_preserved_regardless_of_ph() {
1431 let mut residue = residue_from_template("CYS", StandardResidue::CYS, 1);
1432 residue.name = "CYX".into();
1433 let mut structure = structure_with_residue(residue);
1434 let config = HydroConfig {
1435 target_ph: Some(9.0),
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, "CYX", "CYX should be preserved regardless of pH");
1443 }
1444
1445 #[test]
1446 fn tyr_deprotonates_to_tym_above_pka() {
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: Some(11.0),
1451 ..HydroConfig::default()
1452 };
1453
1454 add_hydrogens(&mut structure, &config).unwrap();
1455
1456 let res = structure.find_residue("A", 1, None).unwrap();
1457 assert_eq!(res.name, "TYM", "TYR should become TYM above pKa 10.0");
1458 }
1459
1460 #[test]
1461 fn tyr_remains_protonated_below_pka() {
1462 let residue = residue_from_template("TYR", StandardResidue::TYR, 1);
1463 let mut structure = structure_with_residue(residue);
1464 let config = HydroConfig {
1465 target_ph: Some(7.4),
1466 ..HydroConfig::default()
1467 };
1468
1469 add_hydrogens(&mut structure, &config).unwrap();
1470
1471 let res = structure.find_residue("A", 1, None).unwrap();
1472 assert_eq!(res.name, "TYR", "TYR should remain TYR below pKa 10.0");
1473 }
1474
1475 #[test]
1476 fn tyr_preserves_original_name_without_ph() {
1477 let residue = residue_from_template("TYR", StandardResidue::TYR, 1);
1478 let mut structure = structure_with_residue(residue);
1479 let config = HydroConfig {
1480 target_ph: None,
1481 his_salt_bridge_protonation: false,
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, "TYR", "TYR should be preserved without pH");
1489 }
1490
1491 #[test]
1492 fn arg_deprotonates_to_arn_above_pka() {
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: Some(13.0),
1497 ..HydroConfig::default()
1498 };
1499
1500 add_hydrogens(&mut structure, &config).unwrap();
1501
1502 let res = structure.find_residue("A", 1, None).unwrap();
1503 assert_eq!(res.name, "ARN", "ARG should become ARN above pKa 12.5");
1504 }
1505
1506 #[test]
1507 fn arg_remains_protonated_below_pka() {
1508 let residue = residue_from_template("ARG", StandardResidue::ARG, 1);
1509 let mut structure = structure_with_residue(residue);
1510 let config = HydroConfig {
1511 target_ph: Some(7.4),
1512 ..HydroConfig::default()
1513 };
1514
1515 add_hydrogens(&mut structure, &config).unwrap();
1516
1517 let res = structure.find_residue("A", 1, None).unwrap();
1518 assert_eq!(res.name, "ARG", "ARG should remain ARG below pKa 12.5");
1519 }
1520
1521 #[test]
1522 fn arg_preserves_original_name_without_ph() {
1523 let residue = residue_from_template("ARG", StandardResidue::ARG, 1);
1524 let mut structure = structure_with_residue(residue);
1525 let config = HydroConfig {
1526 target_ph: None,
1527 his_salt_bridge_protonation: false,
1528 ..HydroConfig::default()
1529 };
1530
1531 add_hydrogens(&mut structure, &config).unwrap();
1532
1533 let res = structure.find_residue("A", 1, None).unwrap();
1534 assert_eq!(res.name, "ARG", "ARG should be preserved without pH");
1535 }
1536
1537 #[test]
1538 fn coo_grid_includes_asp_oxygens_when_deprotonated() {
1539 let residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1540 let structure = structure_with_residue(residue);
1541
1542 let grid = build_carboxylate_grid(&structure, Some(7.4));
1543 let asp = structure.find_residue("A", 1, None).unwrap();
1544 let od1 = asp.atom("OD1").unwrap().pos;
1545
1546 let neighbors: Vec<_> = grid.neighbors(&od1, 0.1).exact().collect();
1547 assert!(!neighbors.is_empty(), "ASP OD1 should be in COO⁻ grid");
1548 }
1549
1550 #[test]
1551 fn coo_grid_excludes_ash_oxygens_when_protonated() {
1552 let mut residue = residue_from_template("ASP", StandardResidue::ASP, 1);
1553 residue.name = "ASH".into();
1554 let structure = structure_with_residue(residue);
1555
1556 let grid = build_carboxylate_grid(&structure, Some(7.4));
1557 let ash = structure.find_residue("A", 1, None).unwrap();
1558 let od1 = ash.atom("OD1").unwrap().pos;
1559
1560 let neighbors: Vec<_> = grid.neighbors(&od1, 0.1).exact().collect();
1561 assert!(
1562 neighbors.is_empty(),
1563 "ASH oxygens should NOT be in COO⁻ grid"
1564 );
1565 }
1566
1567 #[test]
1568 fn coo_grid_includes_glu_oxygens_when_deprotonated() {
1569 let residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1570 let structure = structure_with_residue(residue);
1571
1572 let grid = build_carboxylate_grid(&structure, Some(7.4));
1573 let glu = structure.find_residue("A", 1, None).unwrap();
1574 let oe1 = glu.atom("OE1").unwrap().pos;
1575
1576 let neighbors: Vec<_> = grid.neighbors(&oe1, 0.1).exact().collect();
1577 assert!(!neighbors.is_empty(), "GLU OE1 should be in COO⁻ grid");
1578 }
1579
1580 #[test]
1581 fn coo_grid_excludes_glh_oxygens_when_protonated() {
1582 let mut residue = residue_from_template("GLU", StandardResidue::GLU, 1);
1583 residue.name = "GLH".into();
1584 let structure = structure_with_residue(residue);
1585
1586 let grid = build_carboxylate_grid(&structure, Some(7.4));
1587 let glh = structure.find_residue("A", 1, None).unwrap();
1588 let oe1 = glh.atom("OE1").unwrap().pos;
1589
1590 let neighbors: Vec<_> = grid.neighbors(&oe1, 0.1).exact().collect();
1591 assert!(
1592 neighbors.is_empty(),
1593 "GLH oxygens should NOT be in COO⁻ grid"
1594 );
1595 }
1596
1597 #[test]
1598 fn coo_grid_includes_c_term_oxygens_at_neutral_ph() {
1599 let residue = c_terminal_residue(1);
1600 let structure = structure_with_residue(residue);
1601
1602 let grid = build_carboxylate_grid(&structure, Some(7.4));
1603 let c_term = structure.find_residue("A", 1, None).unwrap();
1604 let oxt = c_term.atom("OXT").unwrap().pos;
1605
1606 let neighbors: Vec<_> = grid.neighbors(&oxt, 0.1).exact().collect();
1607 assert!(
1608 !neighbors.is_empty(),
1609 "C-term OXT should be in COO⁻ grid at pH 7.4"
1610 );
1611 }
1612
1613 #[test]
1614 fn coo_grid_excludes_c_term_oxygens_below_pka() {
1615 let residue = c_terminal_residue(1);
1616 let structure = structure_with_residue(residue);
1617
1618 let grid = build_carboxylate_grid(&structure, Some(2.0));
1619 let c_term = structure.find_residue("A", 1, None).unwrap();
1620 let oxt = c_term.atom("OXT").unwrap().pos;
1621
1622 let neighbors: Vec<_> = grid.neighbors(&oxt, 0.1).exact().collect();
1623 assert!(
1624 neighbors.is_empty(),
1625 "C-term OXT should NOT be in COO⁻ grid below pKa 3.1"
1626 );
1627 }
1628
1629 #[test]
1630 fn coo_grid_uses_default_ph_when_target_ph_unset() {
1631 let residue = c_terminal_residue(1);
1632 let structure = structure_with_residue(residue);
1633
1634 let grid = build_carboxylate_grid(&structure, None);
1635 let c_term = structure.find_residue("A", 1, None).unwrap();
1636 let oxt = c_term.atom("OXT").unwrap().pos;
1637
1638 let neighbors: Vec<_> = grid.neighbors(&oxt, 0.1).exact().collect();
1639 assert!(
1640 !neighbors.is_empty(),
1641 "C-term OXT should be in COO⁻ grid with default pH 7.0"
1642 );
1643 }
1644
1645 #[test]
1646 fn his_becomes_hip_below_pka_threshold() {
1647 let mut structure = his_isolated(1);
1648 let config = HydroConfig {
1649 target_ph: Some(5.5),
1650 his_salt_bridge_protonation: false,
1651 ..HydroConfig::default()
1652 };
1653
1654 add_hydrogens(&mut structure, &config).unwrap();
1655
1656 let res = structure.find_residue("A", 1, None).unwrap();
1657 assert_eq!(res.name, "HIP", "HIS should become HIP below pKa 6.0");
1658 }
1659
1660 #[test]
1661 fn his_does_not_become_hip_above_pka_threshold() {
1662 let mut structure = his_isolated(1);
1663 let config = HydroConfig {
1664 target_ph: Some(7.4),
1665 his_salt_bridge_protonation: false,
1666 his_strategy: HisStrategy::DirectHIE,
1667 ..HydroConfig::default()
1668 };
1669
1670 add_hydrogens(&mut structure, &config).unwrap();
1671
1672 let res = structure.find_residue("A", 1, None).unwrap();
1673 assert_eq!(
1674 res.name, "HIE",
1675 "HIS should become HIE (not HIP) above pKa 6.0"
1676 );
1677 }
1678
1679 #[test]
1680 fn his_becomes_hip_when_nd1_near_asp_carboxylate() {
1681 let mut structure = his_near_asp(1, 2, 3.5);
1682 let config = HydroConfig {
1683 target_ph: Some(7.4),
1684 his_salt_bridge_protonation: true,
1685 ..HydroConfig::default()
1686 };
1687
1688 add_hydrogens(&mut structure, &config).unwrap();
1689
1690 let his = structure.find_residue("A", 1, None).unwrap();
1691 assert_eq!(
1692 his.name, "HIP",
1693 "HIS near ASP should become HIP via salt bridge"
1694 );
1695 }
1696
1697 #[test]
1698 fn his_becomes_hip_when_ne2_near_glu_carboxylate() {
1699 let mut structure = his_near_glu(1, 2, 3.5);
1700 let config = HydroConfig {
1701 target_ph: Some(7.4),
1702 his_salt_bridge_protonation: true,
1703 ..HydroConfig::default()
1704 };
1705
1706 add_hydrogens(&mut structure, &config).unwrap();
1707
1708 let his = structure.find_residue("A", 1, None).unwrap();
1709 assert_eq!(
1710 his.name, "HIP",
1711 "HIS near GLU should become HIP via salt bridge"
1712 );
1713 }
1714
1715 #[test]
1716 fn his_becomes_hip_when_near_c_term_carboxylate() {
1717 let mut structure = his_near_c_term(1, 2, 3.5);
1718 let config = HydroConfig {
1719 target_ph: Some(7.4),
1720 his_salt_bridge_protonation: true,
1721 ..HydroConfig::default()
1722 };
1723
1724 add_hydrogens(&mut structure, &config).unwrap();
1725
1726 let his = structure.find_residue("A", 1, None).unwrap();
1727 assert_eq!(
1728 his.name, "HIP",
1729 "HIS near C-term COO⁻ should become HIP via salt bridge"
1730 );
1731 }
1732
1733 #[test]
1734 fn his_remains_neutral_when_beyond_salt_bridge_threshold() {
1735 let mut structure = his_near_asp(1, 2, 10.0);
1736 let config = HydroConfig {
1737 target_ph: Some(7.4),
1738 his_salt_bridge_protonation: true,
1739 his_strategy: HisStrategy::DirectHIE,
1740 ..HydroConfig::default()
1741 };
1742
1743 add_hydrogens(&mut structure, &config).unwrap();
1744
1745 let his = structure.find_residue("A", 1, None).unwrap();
1746 assert_eq!(
1747 his.name, "HIE",
1748 "HIS beyond salt bridge distance should remain neutral"
1749 );
1750 }
1751
1752 #[test]
1753 fn his_salt_bridge_detected_without_ph() {
1754 let mut structure = his_near_asp(1, 2, 3.5);
1755 let config = HydroConfig {
1756 target_ph: None,
1757 his_salt_bridge_protonation: true,
1758 ..HydroConfig::default()
1759 };
1760
1761 add_hydrogens(&mut structure, &config).unwrap();
1762
1763 let his = structure.find_residue("A", 1, None).unwrap();
1764 assert_eq!(
1765 his.name, "HIP",
1766 "salt bridge should be detected even without pH"
1767 );
1768 }
1769
1770 #[test]
1771 fn his_salt_bridge_skipped_when_option_disabled() {
1772 let mut structure = his_near_asp(1, 2, 3.5);
1773 let config = HydroConfig {
1774 target_ph: Some(7.4),
1775 his_salt_bridge_protonation: false,
1776 his_strategy: HisStrategy::DirectHIE,
1777 ..HydroConfig::default()
1778 };
1779
1780 add_hydrogens(&mut structure, &config).unwrap();
1781
1782 let his = structure.find_residue("A", 1, None).unwrap();
1783 assert_eq!(
1784 his.name, "HIE",
1785 "salt bridge should be ignored when disabled"
1786 );
1787 }
1788
1789 #[test]
1790 fn his_uses_direct_hid_strategy() {
1791 let mut structure = his_isolated(1);
1792 let config = HydroConfig {
1793 target_ph: Some(7.4),
1794 his_salt_bridge_protonation: false,
1795 his_strategy: HisStrategy::DirectHID,
1796 ..HydroConfig::default()
1797 };
1798
1799 add_hydrogens(&mut structure, &config).unwrap();
1800
1801 let res = structure.find_residue("A", 1, None).unwrap();
1802 assert_eq!(res.name, "HID", "DirectHID strategy should produce HID");
1803 }
1804
1805 #[test]
1806 fn his_uses_direct_hie_strategy() {
1807 let mut structure = his_isolated(1);
1808 let config = HydroConfig {
1809 target_ph: Some(7.4),
1810 his_salt_bridge_protonation: false,
1811 his_strategy: HisStrategy::DirectHIE,
1812 ..HydroConfig::default()
1813 };
1814
1815 add_hydrogens(&mut structure, &config).unwrap();
1816
1817 let res = structure.find_residue("A", 1, None).unwrap();
1818 assert_eq!(res.name, "HIE", "DirectHIE strategy should produce HIE");
1819 }
1820
1821 #[test]
1822 fn his_uses_random_strategy_produces_valid_tautomer() {
1823 let mut structure = his_isolated(1);
1824 let config = HydroConfig {
1825 target_ph: Some(7.4),
1826 his_salt_bridge_protonation: false,
1827 his_strategy: HisStrategy::Random,
1828 ..HydroConfig::default()
1829 };
1830
1831 add_hydrogens(&mut structure, &config).unwrap();
1832
1833 let res = structure.find_residue("A", 1, None).unwrap();
1834 assert!(
1835 res.name == "HID" || res.name == "HIE",
1836 "random strategy should produce either HID or HIE, got {}",
1837 res.name
1838 );
1839 }
1840
1841 #[test]
1842 fn his_uses_hb_network_strategy_defaults_to_hie_without_neighbors() {
1843 let mut structure = his_isolated(1);
1844 let config = HydroConfig {
1845 target_ph: Some(7.4),
1846 his_salt_bridge_protonation: false,
1847 his_strategy: HisStrategy::HbNetwork,
1848 ..HydroConfig::default()
1849 };
1850
1851 add_hydrogens(&mut structure, &config).unwrap();
1852
1853 let res = structure.find_residue("A", 1, None).unwrap();
1854 assert!(
1855 res.name == "HID" || res.name == "HIE",
1856 "HbNetwork strategy should produce HID or HIE"
1857 );
1858 }
1859
1860 #[test]
1861 fn his_preserves_hid_name_without_ph_and_no_salt_bridge() {
1862 let mut residue = residue_from_template("HID", StandardResidue::HIS, 1);
1863 residue.name = "HID".into();
1864 let mut structure = structure_with_residue(residue);
1865 let config = HydroConfig {
1866 target_ph: None,
1867 his_salt_bridge_protonation: false,
1868 ..HydroConfig::default()
1869 };
1870
1871 add_hydrogens(&mut structure, &config).unwrap();
1872
1873 let res = structure.find_residue("A", 1, None).unwrap();
1874 assert_eq!(
1875 res.name, "HID",
1876 "HID should be preserved without pH and no salt bridge"
1877 );
1878 }
1879
1880 #[test]
1881 fn his_preserves_hie_name_without_ph_and_no_salt_bridge() {
1882 let mut residue = residue_from_template("HIE", StandardResidue::HIS, 1);
1883 residue.name = "HIE".into();
1884 let mut structure = structure_with_residue(residue);
1885 let config = HydroConfig {
1886 target_ph: None,
1887 his_salt_bridge_protonation: false,
1888 ..HydroConfig::default()
1889 };
1890
1891 add_hydrogens(&mut structure, &config).unwrap();
1892
1893 let res = structure.find_residue("A", 1, None).unwrap();
1894 assert_eq!(
1895 res.name, "HIE",
1896 "HIE should be preserved without pH and no salt bridge"
1897 );
1898 }
1899
1900 #[test]
1901 fn his_preserves_hip_name_without_ph_and_no_salt_bridge() {
1902 let mut residue = residue_from_template("HIP", StandardResidue::HIS, 1);
1903 residue.name = "HIP".into();
1904 let mut structure = structure_with_residue(residue);
1905 let config = HydroConfig {
1906 target_ph: None,
1907 his_salt_bridge_protonation: false,
1908 ..HydroConfig::default()
1909 };
1910
1911 add_hydrogens(&mut structure, &config).unwrap();
1912
1913 let res = structure.find_residue("A", 1, None).unwrap();
1914 assert_eq!(
1915 res.name, "HIP",
1916 "HIP should be preserved without pH and no salt bridge"
1917 );
1918 }
1919
1920 #[test]
1921 fn his_preserves_name_without_ph_when_no_salt_bridge_found() {
1922 let mut residue = residue_from_template("HID", StandardResidue::HIS, 1);
1923 residue.name = "HID".into();
1924 let mut structure = structure_with_residue(residue);
1925 let config = HydroConfig {
1926 target_ph: None,
1927 his_salt_bridge_protonation: true,
1928 ..HydroConfig::default()
1929 };
1930
1931 add_hydrogens(&mut structure, &config).unwrap();
1932
1933 let res = structure.find_residue("A", 1, None).unwrap();
1934 assert_eq!(
1935 res.name, "HID",
1936 "HID should be preserved when salt bridge not found"
1937 );
1938 }
1939
1940 #[test]
1941 fn his_acidic_ph_overrides_salt_bridge_check() {
1942 let mut structure = his_near_asp(1, 2, 3.5);
1943 let config = HydroConfig {
1944 target_ph: Some(5.0),
1945 his_salt_bridge_protonation: true,
1946 ..HydroConfig::default()
1947 };
1948
1949 add_hydrogens(&mut structure, &config).unwrap();
1950
1951 let his = structure.find_residue("A", 1, None).unwrap();
1952 assert_eq!(
1953 his.name, "HIP",
1954 "acidic pH should result in HIP (priority 1)"
1955 );
1956 }
1957
1958 #[test]
1959 fn his_salt_bridge_overrides_strategy_selection() {
1960 let mut structure = his_near_asp(1, 2, 3.5);
1961 let config = HydroConfig {
1962 target_ph: Some(7.4),
1963 his_salt_bridge_protonation: true,
1964 his_strategy: HisStrategy::DirectHIE,
1965 ..HydroConfig::default()
1966 };
1967
1968 add_hydrogens(&mut structure, &config).unwrap();
1969
1970 let his = structure.find_residue("A", 1, None).unwrap();
1971 assert_eq!(
1972 his.name, "HIP",
1973 "salt bridge should override strategy (priority 3 > 5)"
1974 );
1975 }
1976
1977 #[test]
1978 fn his_strategy_applies_only_after_salt_bridge_miss() {
1979 let mut structure = his_near_asp(1, 2, 10.0);
1980 let config = HydroConfig {
1981 target_ph: Some(7.4),
1982 his_salt_bridge_protonation: true,
1983 his_strategy: HisStrategy::DirectHID,
1984 ..HydroConfig::default()
1985 };
1986
1987 add_hydrogens(&mut structure, &config).unwrap();
1988
1989 let his = structure.find_residue("A", 1, None).unwrap();
1990 assert_eq!(
1991 his.name, "HID",
1992 "strategy should apply when salt bridge not found"
1993 );
1994 }
1995
1996 #[test]
1997 fn add_hydrogens_populates_internal_lysine_side_chain() {
1998 let mut residue = residue_from_template("LYS", StandardResidue::LYS, 10);
1999 residue.add_atom(Atom::new("FAKE", Element::H, Point::origin()));
2000 let mut structure = structure_with_residue(residue);
2001
2002 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2003
2004 let residue = structure.find_residue("A", 10, None).unwrap();
2005 assert!(residue.has_atom("HZ1"));
2006 assert!(residue.has_atom("HZ2"));
2007 assert!(residue.has_atom("HZ3"));
2008 assert!(
2009 !residue.has_atom("FAKE"),
2010 "existing H should be removed by default"
2011 );
2012 }
2013
2014 #[test]
2015 fn add_hydrogens_keeps_existing_h_when_configured() {
2016 let mut residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2017 residue.add_atom(Atom::new("HX", Element::H, Point::origin()));
2018 let mut structure = structure_with_residue(residue);
2019 let config = HydroConfig {
2020 remove_existing_h: false,
2021 ..HydroConfig::default()
2022 };
2023
2024 add_hydrogens(&mut structure, &config).unwrap();
2025
2026 let residue = structure.find_residue("A", 1, None).unwrap();
2027 assert!(
2028 residue.has_atom("HX"),
2029 "existing H should be kept when configured"
2030 );
2031 }
2032
2033 #[test]
2034 fn construct_hydrogens_errors_when_anchor_missing() {
2035 let template = db::get_template("ALA").expect("template ALA");
2036 let mut residue = Residue::new(
2037 20,
2038 None,
2039 "ALA",
2040 Some(StandardResidue::ALA),
2041 ResidueCategory::Standard,
2042 );
2043 residue.position = ResiduePosition::Internal;
2044
2045 let (name, element, pos) = template.heavy_atoms().next().unwrap();
2046 residue.add_atom(Atom::new(name, element, pos));
2047
2048 let err = construct_hydrogens_for_residue(&mut residue, &HydroConfig::default())
2049 .expect_err("should fail");
2050 assert!(matches!(err, Error::IncompleteResidueForHydro { .. }));
2051 }
2052
2053 #[test]
2054 fn close_cysteines_are_relabeled_to_cyx() {
2055 let cys1 = residue_from_template("CYS", StandardResidue::CYS, 30);
2056 let mut cys2 = residue_from_template("CYS", StandardResidue::CYS, 31);
2057
2058 let sg1 = cys1.atom("SG").expect("SG in cys1").pos;
2059 let sg2 = cys2.atom("SG").expect("SG in cys2").pos;
2060 let desired = sg1 + Vector3::new(0.5, 0.0, 0.0);
2061 let offset = desired - sg2;
2062 for atom in cys2.iter_atoms_mut() {
2063 atom.translate_by(&offset);
2064 }
2065
2066 let mut structure = structure_with_residues(vec![cys1, cys2]);
2067 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2068
2069 let res1 = structure.find_residue("A", 30, None).unwrap();
2070 let res2 = structure.find_residue("A", 31, None).unwrap();
2071 assert_eq!(res1.name, "CYX");
2072 assert_eq!(res2.name, "CYX");
2073 }
2074
2075 #[test]
2076 fn close_cysteines_skip_hg_hydrogen() {
2077 let cys1 = residue_from_template("CYS", StandardResidue::CYS, 30);
2078 let mut cys2 = residue_from_template("CYS", StandardResidue::CYS, 31);
2079
2080 let sg1 = cys1.atom("SG").expect("SG in cys1").pos;
2081 let sg2 = cys2.atom("SG").expect("SG in cys2").pos;
2082 let desired = sg1 + Vector3::new(0.5, 0.0, 0.0);
2083 let offset = desired - sg2;
2084 for atom in cys2.iter_atoms_mut() {
2085 atom.translate_by(&offset);
2086 }
2087
2088 let mut structure = structure_with_residues(vec![cys1, cys2]);
2089 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2090
2091 let res1 = structure.find_residue("A", 30, None).unwrap();
2092 let res2 = structure.find_residue("A", 31, None).unwrap();
2093 assert!(!res1.has_atom("HG"), "CYX should not have HG");
2094 assert!(!res2.has_atom("HG"), "CYX should not have HG");
2095 }
2096
2097 #[test]
2098 fn distant_cysteines_remain_unchanged() {
2099 let cys1 = residue_from_template("CYS", StandardResidue::CYS, 30);
2100 let mut cys2 = residue_from_template("CYS", StandardResidue::CYS, 31);
2101
2102 let offset = Vector3::new(20.0, 0.0, 0.0);
2103 for atom in cys2.iter_atoms_mut() {
2104 atom.translate_by(&offset);
2105 }
2106
2107 let mut structure = structure_with_residues(vec![cys1, cys2]);
2108 let config = HydroConfig {
2109 target_ph: Some(7.4),
2110 ..HydroConfig::default()
2111 };
2112 add_hydrogens(&mut structure, &config).unwrap();
2113
2114 let res1 = structure.find_residue("A", 30, None).unwrap();
2115 let res2 = structure.find_residue("A", 31, None).unwrap();
2116 assert_eq!(res1.name, "CYS", "distant CYS should remain CYS");
2117 assert_eq!(res2.name, "CYS", "distant CYS should remain CYS");
2118 }
2119
2120 #[test]
2121 fn n_terminal_defaults_to_protonated_without_ph() {
2122 let residue = n_terminal_residue(40);
2123 let mut structure = structure_with_residue(residue);
2124
2125 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2126
2127 let residue = structure.find_residue("A", 40, None).unwrap();
2128 assert!(residue.has_atom("H1"));
2129 assert!(residue.has_atom("H2"));
2130 assert!(
2131 residue.has_atom("H3"),
2132 "N-term should have 3 H at default pH 7.0"
2133 );
2134 }
2135
2136 #[test]
2137 fn n_terminal_remains_protonated_below_pka() {
2138 let residue = n_terminal_residue(40);
2139 let mut structure = structure_with_residue(residue);
2140 let config = HydroConfig {
2141 target_ph: Some(7.0),
2142 ..HydroConfig::default()
2143 };
2144
2145 add_hydrogens(&mut structure, &config).unwrap();
2146
2147 let residue = structure.find_residue("A", 40, None).unwrap();
2148 assert!(residue.has_atom("H1"));
2149 assert!(residue.has_atom("H2"));
2150 assert!(
2151 residue.has_atom("H3"),
2152 "N-term should have 3 H below pKa 8.0"
2153 );
2154 }
2155
2156 #[test]
2157 fn n_terminal_deprotonates_above_pka() {
2158 let residue = n_terminal_residue(41);
2159 let mut structure = structure_with_residue(residue);
2160 let config = HydroConfig {
2161 target_ph: Some(9.0),
2162 ..HydroConfig::default()
2163 };
2164
2165 add_hydrogens(&mut structure, &config).unwrap();
2166
2167 let residue = structure.find_residue("A", 41, None).unwrap();
2168 assert!(residue.has_atom("H1"));
2169 assert!(residue.has_atom("H2"));
2170 assert!(
2171 !residue.has_atom("H3"),
2172 "N-term should have only 2 H above pKa 8.0"
2173 );
2174 }
2175
2176 #[test]
2177 fn n_terminal_h_has_tetrahedral_bond_lengths() {
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 h1 = residue.atom("H1").expect("H1").pos;
2186 let h2 = residue.atom("H2").expect("H2").pos;
2187 let h3 = residue.atom("H3").expect("H3").pos;
2188
2189 let n_h1_dist = distance(n, h1);
2190 let n_h2_dist = distance(n, h2);
2191 let n_h3_dist = distance(n, h3);
2192 assert!(
2193 (n_h1_dist - NH_BOND_LENGTH).abs() < 0.1,
2194 "N-H1 distance {n_h1_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2195 );
2196 assert!(
2197 (n_h2_dist - NH_BOND_LENGTH).abs() < 0.1,
2198 "N-H2 distance {n_h2_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2199 );
2200 assert!(
2201 (n_h3_dist - NH_BOND_LENGTH).abs() < 0.1,
2202 "N-H3 distance {n_h3_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2203 );
2204 }
2205
2206 #[test]
2207 fn n_terminal_h_has_tetrahedral_bond_angles() {
2208 let residue = n_terminal_residue(98);
2209 let mut structure = structure_with_residue(residue);
2210
2211 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2212
2213 let residue = structure.find_residue("A", 98, None).unwrap();
2214 let n = residue.atom("N").expect("N").pos;
2215 let ca = residue.atom("CA").expect("CA").pos;
2216 let h1 = residue.atom("H1").expect("H1").pos;
2217 let h2 = residue.atom("H2").expect("H2").pos;
2218 let h3 = residue.atom("H3").expect("H3").pos;
2219
2220 let ca_n_h1_angle = angle_deg(ca, n, h1);
2221 let ca_n_h2_angle = angle_deg(ca, n, h2);
2222 let ca_n_h3_angle = angle_deg(ca, n, h3);
2223 let h1_n_h2_angle = angle_deg(h1, n, h2);
2224 let h2_n_h3_angle = angle_deg(h2, n, h3);
2225 let h1_n_h3_angle = angle_deg(h1, n, h3);
2226
2227 assert!(
2228 (ca_n_h1_angle - SP3_ANGLE).abs() < 5.0,
2229 "CA-N-H1 angle {ca_n_h1_angle:.1}° should be ~{SP3_ANGLE}°"
2230 );
2231 assert!(
2232 (ca_n_h2_angle - SP3_ANGLE).abs() < 5.0,
2233 "CA-N-H2 angle {ca_n_h2_angle:.1}° should be ~{SP3_ANGLE}°"
2234 );
2235 assert!(
2236 (ca_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2237 "CA-N-H3 angle {ca_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2238 );
2239 assert!(
2240 (h1_n_h2_angle - SP3_ANGLE).abs() < 5.0,
2241 "H1-N-H2 angle {h1_n_h2_angle:.1}° should be ~{SP3_ANGLE}°"
2242 );
2243 assert!(
2244 (h2_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2245 "H2-N-H3 angle {h2_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2246 );
2247 assert!(
2248 (h1_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2249 "H1-N-H3 angle {h1_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2250 );
2251 }
2252
2253 #[test]
2254 fn pro_n_terminal_defaults_to_protonated_without_ph() {
2255 let residue = n_terminal_pro_residue(42);
2256 let mut structure = structure_with_residue(residue);
2257
2258 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2259
2260 let residue = structure.find_residue("A", 42, None).unwrap();
2261 assert!(
2262 !residue.has_atom("H1"),
2263 "PRO N-term must not have H1 (ring N-CD occupies that valence)"
2264 );
2265 assert!(residue.has_atom("H2"));
2266 assert!(
2267 residue.has_atom("H3"),
2268 "PRO N-term should have 2 H at default pH 7.0"
2269 );
2270 }
2271
2272 #[test]
2273 fn pro_n_terminal_remains_protonated_below_pka() {
2274 let residue = n_terminal_pro_residue(42);
2275 let mut structure = structure_with_residue(residue);
2276 let config = HydroConfig {
2277 target_ph: Some(7.0),
2278 ..HydroConfig::default()
2279 };
2280
2281 add_hydrogens(&mut structure, &config).unwrap();
2282
2283 let residue = structure.find_residue("A", 42, None).unwrap();
2284 assert!(
2285 !residue.has_atom("H1"),
2286 "PRO N-term must not have H1 (ring N-CD occupies that valence)"
2287 );
2288 assert!(residue.has_atom("H2"));
2289 assert!(
2290 residue.has_atom("H3"),
2291 "PRO N-term should have 2 H below pKa 8.0"
2292 );
2293 }
2294
2295 #[test]
2296 fn pro_n_terminal_deprotonates_above_pka() {
2297 let residue = n_terminal_pro_residue(43);
2298 let mut structure = structure_with_residue(residue);
2299 let config = HydroConfig {
2300 target_ph: Some(9.0),
2301 ..HydroConfig::default()
2302 };
2303
2304 add_hydrogens(&mut structure, &config).unwrap();
2305
2306 let residue = structure.find_residue("A", 43, None).unwrap();
2307 assert!(
2308 !residue.has_atom("H1"),
2309 "PRO N-term must not have H1 (ring N-CD occupies that valence)"
2310 );
2311 assert!(residue.has_atom("H2"));
2312 assert!(
2313 !residue.has_atom("H3"),
2314 "PRO N-term should have only 1 H above pKa 8.0"
2315 );
2316 }
2317
2318 #[test]
2319 fn pro_n_terminal_h_has_tetrahedral_bond_lengths() {
2320 let residue = n_terminal_pro_residue(96);
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", 96, None).unwrap();
2326 let n = residue.atom("N").expect("N").pos;
2327 let h2 = residue.atom("H2").expect("H2").pos;
2328 let h3 = residue.atom("H3").expect("H3").pos;
2329
2330 let n_h2_dist = distance(n, h2);
2331 let n_h3_dist = distance(n, h3);
2332 assert!(
2333 (n_h2_dist - NH_BOND_LENGTH).abs() < 0.1,
2334 "N-H2 distance {n_h2_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2335 );
2336 assert!(
2337 (n_h3_dist - NH_BOND_LENGTH).abs() < 0.1,
2338 "N-H3 distance {n_h3_dist:.3} should be ~{NH_BOND_LENGTH} Å"
2339 );
2340 }
2341
2342 #[test]
2343 fn pro_n_terminal_h_has_tetrahedral_bond_angles() {
2344 let residue = n_terminal_pro_residue(96);
2345 let mut structure = structure_with_residue(residue);
2346
2347 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2348
2349 let residue = structure.find_residue("A", 96, None).unwrap();
2350 let n = residue.atom("N").expect("N").pos;
2351 let ca = residue.atom("CA").expect("CA").pos;
2352 let cd = residue.atom("CD").expect("CD").pos;
2353 let h2 = residue.atom("H2").expect("H2").pos;
2354 let h3 = residue.atom("H3").expect("H3").pos;
2355
2356 let ca_n_h2_angle = angle_deg(ca, n, h2);
2357 let ca_n_h3_angle = angle_deg(ca, n, h3);
2358 let cd_n_h2_angle = angle_deg(cd, n, h2);
2359 let cd_n_h3_angle = angle_deg(cd, n, h3);
2360 let h2_n_h3_angle = angle_deg(h2, n, h3);
2361
2362 assert!(
2363 (ca_n_h2_angle - SP3_ANGLE).abs() < 5.0,
2364 "CA-N-H2 angle {ca_n_h2_angle:.1}° should be ~{SP3_ANGLE}°"
2365 );
2366 assert!(
2367 (ca_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2368 "CA-N-H3 angle {ca_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2369 );
2370 assert!(
2371 (cd_n_h2_angle - SP3_ANGLE).abs() < 5.0,
2372 "CD-N-H2 angle {cd_n_h2_angle:.1}° should be ~{SP3_ANGLE}°"
2373 );
2374 assert!(
2375 (cd_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2376 "CD-N-H3 angle {cd_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2377 );
2378 assert!(
2379 (h2_n_h3_angle - SP3_ANGLE).abs() < 5.0,
2380 "H2-N-H3 angle {h2_n_h3_angle:.1}° should be ~{SP3_ANGLE}°"
2381 );
2382 }
2383
2384 #[test]
2385 fn c_terminal_defaults_to_deprotonated_without_ph() {
2386 let residue = c_terminal_residue(51);
2387 let mut structure = structure_with_residue(residue);
2388
2389 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2390
2391 let residue = structure.find_residue("A", 51, None).unwrap();
2392 assert!(
2393 !residue.has_atom("HOXT"),
2394 "C-term should be deprotonated at default pH 7.0"
2395 );
2396 }
2397
2398 #[test]
2399 fn c_terminal_protonates_under_acidic_ph() {
2400 let residue = c_terminal_residue(50);
2401 let mut structure = structure_with_residue(residue);
2402 let config = HydroConfig {
2403 target_ph: Some(2.5),
2404 ..HydroConfig::default()
2405 };
2406
2407 add_hydrogens(&mut structure, &config).unwrap();
2408
2409 let residue = structure.find_residue("A", 50, None).unwrap();
2410 assert!(
2411 residue.has_atom("HOXT"),
2412 "C-term should be protonated below pKa 3.1"
2413 );
2414 }
2415
2416 #[test]
2417 fn c_terminal_remains_deprotonated_at_physiological_ph() {
2418 let residue = c_terminal_residue(51);
2419 let mut structure = structure_with_residue(residue);
2420 let config = HydroConfig {
2421 target_ph: Some(7.4),
2422 ..HydroConfig::default()
2423 };
2424
2425 add_hydrogens(&mut structure, &config).unwrap();
2426
2427 let residue = structure.find_residue("A", 51, None).unwrap();
2428 assert!(
2429 !residue.has_atom("HOXT"),
2430 "C-term should be deprotonated at pH 7.4"
2431 );
2432 }
2433
2434 #[test]
2435 fn c_terminal_hoxt_has_tetrahedral_bond_length() {
2436 let residue = c_terminal_residue(99);
2437 let mut structure = structure_with_residue(residue);
2438 let config = HydroConfig {
2439 target_ph: Some(2.0),
2440 ..HydroConfig::default()
2441 };
2442
2443 add_hydrogens(&mut structure, &config).unwrap();
2444
2445 let residue = structure.find_residue("A", 99, None).unwrap();
2446 let oxt = residue.atom("OXT").expect("OXT").pos;
2447 let hoxt = residue.atom("HOXT").expect("HOXT").pos;
2448
2449 let oxt_hoxt_dist = distance(oxt, hoxt);
2450 assert!(
2451 (oxt_hoxt_dist - COOH_BOND_LENGTH).abs() < 0.1,
2452 "OXT-HOXT distance {oxt_hoxt_dist:.3} should be ~{COOH_BOND_LENGTH} Å"
2453 );
2454 }
2455
2456 #[test]
2457 fn c_terminal_hoxt_has_tetrahedral_bond_angle() {
2458 let residue = c_terminal_residue(99);
2459 let mut structure = structure_with_residue(residue);
2460 let config = HydroConfig {
2461 target_ph: Some(2.0),
2462 ..HydroConfig::default()
2463 };
2464
2465 add_hydrogens(&mut structure, &config).unwrap();
2466
2467 let residue = structure.find_residue("A", 99, None).unwrap();
2468 let c = residue.atom("C").expect("C").pos;
2469 let oxt = residue.atom("OXT").expect("OXT").pos;
2470 let hoxt = residue.atom("HOXT").expect("HOXT").pos;
2471
2472 let c_oxt_hoxt_angle = angle_deg(c, oxt, hoxt);
2473 assert!(
2474 (c_oxt_hoxt_angle - SP3_ANGLE).abs() < 5.0,
2475 "C-OXT-HOXT angle {c_oxt_hoxt_angle:.1}° should be ~{SP3_ANGLE}°"
2476 );
2477 }
2478
2479 #[test]
2480 fn five_prime_phosphate_deprotonated_at_physiological_ph() {
2481 let residue = five_prime_residue_with_phosphate(60);
2482 let mut structure = structure_with_residue(residue);
2483
2484 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2485
2486 let residue = structure.find_residue("A", 60, None).unwrap();
2487 assert!(residue.has_atom("OP3"), "OP3 should remain");
2488 assert!(
2489 !residue.has_atom("HOP3"),
2490 "HOP3 should not exist at neutral pH"
2491 );
2492 assert!(
2493 !residue.has_atom("HOP2"),
2494 "HOP2 should not exist at neutral pH"
2495 );
2496 }
2497
2498 #[test]
2499 fn five_prime_phosphate_protonated_below_pka() {
2500 let residue = five_prime_residue_with_phosphate(61);
2501 let mut structure = structure_with_residue(residue);
2502 let config = HydroConfig {
2503 target_ph: Some(5.5),
2504 ..HydroConfig::default()
2505 };
2506
2507 add_hydrogens(&mut structure, &config).unwrap();
2508
2509 let residue = structure.find_residue("A", 61, None).unwrap();
2510 assert!(residue.has_atom("OP3"), "OP3 should remain");
2511 assert!(
2512 residue.has_atom("HOP3"),
2513 "HOP3 should be added below pKa 6.5"
2514 );
2515 }
2516
2517 #[test]
2518 fn five_prime_without_phosphate_gets_ho5() {
2519 let residue = five_prime_residue_without_phosphate(62);
2520 let mut structure = structure_with_residue(residue);
2521
2522 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2523
2524 let residue = structure.find_residue("A", 62, None).unwrap();
2525 assert!(
2526 residue.has_atom("HO5'"),
2527 "HO5' should be added for 5'-OH terminus"
2528 );
2529 assert!(!residue.has_atom("P"), "phosphorus should not exist");
2530 }
2531
2532 #[test]
2533 fn five_prime_ho5_has_tetrahedral_geometry() {
2534 let residue = five_prime_residue_without_phosphate(80);
2535 let mut structure = structure_with_residue(residue);
2536
2537 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2538
2539 let residue = structure.find_residue("A", 80, None).unwrap();
2540 let c5 = residue.atom("C5'").expect("C5'").pos;
2541 let o5 = residue.atom("O5'").expect("O5'").pos;
2542 let ho5 = residue.atom("HO5'").expect("HO5'").pos;
2543
2544 let o5_ho5_dist = distance(o5, ho5);
2545 assert!(
2546 (o5_ho5_dist - OH_BOND_LENGTH).abs() < 0.1,
2547 "O5'-HO5' distance {o5_ho5_dist:.3} should be ~{OH_BOND_LENGTH} Å"
2548 );
2549
2550 let c5_o5_ho5_angle = angle_deg(c5, o5, ho5);
2551 assert!(
2552 (c5_o5_ho5_angle - SP3_ANGLE).abs() < 5.0,
2553 "C5'-O5'-HO5' angle {c5_o5_ho5_angle:.1}° should be ~{SP3_ANGLE}°"
2554 );
2555 }
2556
2557 #[test]
2558 fn five_prime_phosphate_hop3_has_tetrahedral_geometry() {
2559 let residue = five_prime_residue_with_phosphate(82);
2560 let mut structure = structure_with_residue(residue);
2561 let config = HydroConfig {
2562 target_ph: Some(5.5),
2563 ..HydroConfig::default()
2564 };
2565
2566 add_hydrogens(&mut structure, &config).unwrap();
2567
2568 let residue = structure.find_residue("A", 82, None).unwrap();
2569 let p = residue.atom("P").expect("P").pos;
2570 let op3 = residue.atom("OP3").expect("OP3").pos;
2571 let hop3 = residue.atom("HOP3").expect("HOP3").pos;
2572
2573 let op3_hop3_dist = distance(op3, hop3);
2574 assert!(
2575 (op3_hop3_dist - OH_BOND_LENGTH).abs() < 0.1,
2576 "OP3-HOP3 distance {op3_hop3_dist:.3} should be ~{OH_BOND_LENGTH} Å"
2577 );
2578
2579 let p_op3_hop3_angle = angle_deg(p, op3, hop3);
2580 assert!(
2581 (p_op3_hop3_angle - SP3_ANGLE).abs() < 5.0,
2582 "P-OP3-HOP3 angle {p_op3_hop3_angle:.1}° should be ~{SP3_ANGLE}°"
2583 );
2584 }
2585
2586 #[test]
2587 fn three_prime_nucleic_gets_ho3() {
2588 let residue = three_prime_residue(70);
2589 let mut structure = structure_with_residue(residue);
2590
2591 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2592
2593 let residue = structure.find_residue("A", 70, None).unwrap();
2594 assert!(
2595 residue.has_atom("HO3'"),
2596 "HO3' should be added for 3' terminal"
2597 );
2598 }
2599
2600 #[test]
2601 fn three_prime_ho3_has_tetrahedral_geometry() {
2602 let residue = three_prime_residue(81);
2603 let mut structure = structure_with_residue(residue);
2604
2605 add_hydrogens(&mut structure, &HydroConfig::default()).unwrap();
2606
2607 let residue = structure.find_residue("A", 81, None).unwrap();
2608 let c3 = residue.atom("C3'").expect("C3'").pos;
2609 let o3 = residue.atom("O3'").expect("O3'").pos;
2610 let ho3 = residue.atom("HO3'").expect("HO3'").pos;
2611
2612 let o3_ho3_dist = distance(o3, ho3);
2613 assert!(
2614 (o3_ho3_dist - OH_BOND_LENGTH).abs() < 0.1,
2615 "O3'-HO3' distance {o3_ho3_dist:.3} should be ~{OH_BOND_LENGTH} Å"
2616 );
2617
2618 let c3_o3_ho3_angle = angle_deg(c3, o3, ho3);
2619 assert!(
2620 (c3_o3_ho3_angle - SP3_ANGLE).abs() < 5.0,
2621 "C3'-O3'-HO3' angle {c3_o3_ho3_angle:.1}° should be ~{SP3_ANGLE}°"
2622 );
2623 }
2624
2625 #[test]
2626 fn hydro_config_defaults_to_no_ph() {
2627 let config = HydroConfig::default();
2628 assert!(config.target_ph.is_none(), "default should have no pH");
2629 }
2630
2631 #[test]
2632 fn hydro_config_defaults_to_remove_existing_h() {
2633 let config = HydroConfig::default();
2634 assert!(config.remove_existing_h, "default should remove existing H");
2635 }
2636
2637 #[test]
2638 fn hydro_config_defaults_to_hb_network_strategy() {
2639 let config = HydroConfig::default();
2640 assert_eq!(
2641 config.his_strategy,
2642 HisStrategy::HbNetwork,
2643 "default should use HbNetwork"
2644 );
2645 }
2646
2647 #[test]
2648 fn hydro_config_defaults_to_salt_bridge_enabled() {
2649 let config = HydroConfig::default();
2650 assert!(
2651 config.his_salt_bridge_protonation,
2652 "default should enable salt bridge detection"
2653 );
2654 }
2655
2656 #[test]
2657 fn effective_terminal_ph_returns_target_when_set() {
2658 assert_eq!(effective_terminal_ph(Some(5.5)), 5.5);
2659 assert_eq!(effective_terminal_ph(Some(9.0)), 9.0);
2660 }
2661
2662 #[test]
2663 fn effective_terminal_ph_returns_default_when_unset() {
2664 assert_eq!(effective_terminal_ph(None), DEFAULT_TERMINAL_PH);
2665 }
2666
2667 #[test]
2668 fn c_terminus_is_deprotonated_at_and_above_pka() {
2669 assert!(c_terminus_is_deprotonated(Some(7.4)));
2670 assert!(c_terminus_is_deprotonated(Some(4.0)));
2671 assert!(c_terminus_is_deprotonated(Some(C_TERM_PKA)));
2672 assert!(c_terminus_is_deprotonated(None));
2673 }
2674
2675 #[test]
2676 fn c_terminus_is_protonated_below_pka() {
2677 assert!(!c_terminus_is_deprotonated(Some(2.0)));
2678 assert!(!c_terminus_is_deprotonated(Some(3.0)));
2679 assert!(!c_terminus_is_deprotonated(Some(C_TERM_PKA - 0.1)));
2680 }
2681
2682 #[test]
2683 fn n_terminus_is_protonated_at_and_below_pka() {
2684 assert!(n_term_is_protonated(Some(7.0)));
2685 assert!(n_term_is_protonated(Some(N_TERM_PKA)));
2686 assert!(n_term_is_protonated(None));
2687 }
2688
2689 #[test]
2690 fn n_terminus_is_deprotonated_above_pka() {
2691 assert!(!n_term_is_protonated(Some(9.0)));
2692 assert!(!n_term_is_protonated(Some(N_TERM_PKA + 0.1)));
2693 }
2694
2695 #[test]
2696 fn c_term_protonation_boundary_is_exclusive() {
2697 assert!(c_terminus_is_deprotonated(Some(C_TERM_PKA)));
2698 assert!(!c_term_is_protonated(Some(C_TERM_PKA)));
2699 }
2700
2701 #[test]
2702 fn acceptor_grid_includes_nitrogen_atoms() {
2703 let residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2704 let structure = structure_with_residue(residue);
2705
2706 let grid = build_acceptor_grid(&structure);
2707 let ala = structure.find_residue("A", 1, None).unwrap();
2708 let n = ala.atom("N").unwrap().pos;
2709
2710 let neighbors: Vec<_> = grid.neighbors(&n, 0.1).exact().collect();
2711 assert!(!neighbors.is_empty(), "N should be in acceptor grid");
2712 }
2713
2714 #[test]
2715 fn acceptor_grid_includes_oxygen_atoms() {
2716 let residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2717 let structure = structure_with_residue(residue);
2718
2719 let grid = build_acceptor_grid(&structure);
2720 let ala = structure.find_residue("A", 1, None).unwrap();
2721 let o = ala.atom("O").unwrap().pos;
2722
2723 let neighbors: Vec<_> = grid.neighbors(&o, 0.1).exact().collect();
2724 assert!(!neighbors.is_empty(), "O should be in acceptor grid");
2725 }
2726
2727 #[test]
2728 fn acceptor_grid_excludes_carbon_atoms() {
2729 let residue = residue_from_template("ALA", StandardResidue::ALA, 1);
2730 let structure = structure_with_residue(residue);
2731
2732 let grid = build_acceptor_grid(&structure);
2733 let ala = structure.find_residue("A", 1, None).unwrap();
2734 let ca = ala.atom("CA").unwrap().pos;
2735
2736 let neighbors: Vec<_> = grid.neighbors(&ca, 0.1).exact().collect();
2737 assert!(neighbors.is_empty(), "CA should NOT be in acceptor grid");
2738 }
2739
2740 #[test]
2741 fn full_pipeline_preserves_user_protonation_without_ph() {
2742 let mut his = residue_from_template("HID", StandardResidue::HIS, 1);
2743 his.name = "HID".into();
2744 let mut ash = residue_from_template("ASP", StandardResidue::ASP, 2);
2745 ash.name = "ASH".into();
2746 let mut structure = structure_with_residues(vec![his, ash]);
2747
2748 let config = HydroConfig {
2749 target_ph: None,
2750 his_salt_bridge_protonation: false,
2751 ..HydroConfig::default()
2752 };
2753
2754 add_hydrogens(&mut structure, &config).unwrap();
2755
2756 let his = structure.find_residue("A", 1, None).unwrap();
2757 let ash = structure.find_residue("A", 2, None).unwrap();
2758 assert_eq!(his.name, "HID", "user-specified HID should be preserved");
2759 assert_eq!(ash.name, "ASH", "user-specified ASH should be preserved");
2760 }
2761
2762 #[test]
2763 fn full_pipeline_applies_all_pka_rules_with_ph() {
2764 let asp = residue_from_template("ASP", StandardResidue::ASP, 1);
2765 let glu = residue_from_template("GLU", StandardResidue::GLU, 2);
2766 let lys = residue_from_template("LYS", StandardResidue::LYS, 3);
2767 let mut structure = structure_with_residues(vec![asp, glu, lys]);
2768
2769 let config = HydroConfig {
2770 target_ph: Some(7.4),
2771 ..HydroConfig::default()
2772 };
2773
2774 add_hydrogens(&mut structure, &config).unwrap();
2775
2776 let asp = structure.find_residue("A", 1, None).unwrap();
2777 let glu = structure.find_residue("A", 2, None).unwrap();
2778 let lys = structure.find_residue("A", 3, None).unwrap();
2779 assert_eq!(asp.name, "ASP", "ASP should remain at pH 7.4");
2780 assert_eq!(glu.name, "GLU", "GLU should remain at pH 7.4");
2781 assert_eq!(lys.name, "LYS", "LYS should remain at pH 7.4");
2782 }
2783
2784 #[test]
2785 fn full_pipeline_detects_salt_bridge_and_converts_his() {
2786 let mut structure = his_near_asp(1, 2, 3.5);
2787 let config = HydroConfig {
2788 target_ph: Some(7.4),
2789 his_salt_bridge_protonation: true,
2790 ..HydroConfig::default()
2791 };
2792
2793 add_hydrogens(&mut structure, &config).unwrap();
2794
2795 let his = structure.find_residue("A", 1, None).unwrap();
2796 let asp = structure.find_residue("A", 2, None).unwrap();
2797 assert_eq!(his.name, "HIP", "HIS should become HIP via salt bridge");
2798 assert_eq!(asp.name, "ASP", "ASP should remain deprotonated");
2799 }
2800
2801 #[test]
2802 fn full_pipeline_with_all_options_disabled() {
2803 let mut his = residue_from_template("HID", StandardResidue::HIS, 1);
2804 his.name = "HID".into();
2805 let mut structure = structure_with_residue(his);
2806
2807 let config = HydroConfig {
2808 target_ph: None,
2809 remove_existing_h: false,
2810 his_salt_bridge_protonation: false,
2811 his_strategy: HisStrategy::DirectHIE,
2812 };
2813
2814 add_hydrogens(&mut structure, &config).unwrap();
2815
2816 let his = structure.find_residue("A", 1, None).unwrap();
2817 assert_eq!(
2818 his.name, "HID",
2819 "HID should be preserved with all options disabled"
2820 );
2821 }
2822}