Skip to main content

bio_forge/ops/
hydro.rs

1//! Protonation-aware hydrogen placement for protein and nucleic acid structures.
2//!
3//! This module inspects residue templates, predicts protonation states from pH or hydrogen
4//! bonding networks, relabels residues accordingly, and rebuilds missing hydrogens while
5//! respecting polymer termini, nucleic acid priming, disulfide bridges, and salt bridges.
6
7use 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
21/// Henderson–Hasselbalch breakpoint for histidine double-protonation (HIP).
22const HIS_HIP_PKA: f64 = 6.0;
23/// Henderson–Hasselbalch breakpoint for aspartate protonation (ASH).
24const ASP_PKA: f64 = 3.9;
25/// Henderson–Hasselbalch breakpoint for glutamate protonation (GLH).
26const GLU_PKA: f64 = 4.2;
27/// Henderson–Hasselbalch breakpoint for lysine deprotonation (LYN).
28const LYS_PKA: f64 = 10.5;
29/// Henderson–Hasselbalch breakpoint for arginine deprotonation (ARN).
30const ARG_PKA: f64 = 12.5;
31/// Henderson–Hasselbalch breakpoint for cysteine deprotonation (CYM).
32const CYS_PKA: f64 = 8.3;
33/// Henderson–Hasselbalch breakpoint for tyrosine deprotonation (TYM).
34const TYR_PKA: f64 = 10.0;
35/// Henderson–Hasselbalch breakpoint for protonated N-termini.
36const N_TERM_PKA: f64 = 8.0;
37/// Henderson–Hasselbalch breakpoint for protonated C-termini.
38const C_TERM_PKA: f64 = 3.1;
39/// Henderson–Hasselbalch breakpoint for the second dissociation of terminal phosphate.
40const PHOSPHATE_PKA2: f64 = 6.5;
41/// Default assumed pH for terminal state decisions when no explicit pH is specified.
42const DEFAULT_TERMINAL_PH: f64 = 7.0;
43/// Maximum sulfur–sulfur distance (Å) for disulfide bridge detection.
44const DISULFIDE_SG_THRESHOLD: f64 = 2.2;
45/// Maximum nitrogen–oxygen distance (Å) for HIS-carboxylate salt bridge detection.
46const SALT_BRIDGE_DISTANCE: f64 = 4.0;
47/// Standard sp³ tetrahedral bond angle (degrees).
48const SP3_ANGLE: f64 = 109.5;
49/// Standard N-H bond length (Å).
50const NH_BOND_LENGTH: f64 = 1.01;
51/// Standard O-H bond length (Å).
52const OH_BOND_LENGTH: f64 = 0.96;
53/// Carboxylic acid O-H bond length (Å).
54const COOH_BOND_LENGTH: f64 = 0.97;
55
56/// Parameters controlling hydrogen addition behavior.
57///
58/// `HydroConfig` can target a specific solution pH, remove pre-existing hydrogens,
59/// choose how neutral histidine tautomers are assigned, and enable/disable salt bridge detection.
60#[derive(Debug, Clone)]
61pub struct HydroConfig {
62    /// Optional solvent pH value used for titration decisions.
63    pub target_ph: Option<f64>,
64    /// Whether to strip all existing hydrogens before reconstruction.
65    pub remove_existing_h: bool,
66    /// Strategy for selecting neutral histidine tautomers (HID/HIE).
67    pub his_strategy: HisStrategy,
68    /// Whether to protonate histidine to HIP when forming salt bridges with
69    /// nearby carboxylate groups (ASP⁻/GLU⁻/C-terminal COO⁻).
70    pub his_salt_bridge_protonation: bool,
71}
72
73impl Default for HydroConfig {
74    /// Provides biologically reasonable defaults (no protonation state changes,
75    /// removal of old hydrogens, hydrogen-bond-aware histidine selection,
76    /// and HIS salt bridge detection).
77    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/// Strategies for selecting neutral histidine tautomers.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum HisStrategy {
90    /// Force all neutral histidines to HID (δ-protonated).
91    DirectHID,
92    /// Force all neutral histidines to HIE (ε-protonated).
93    DirectHIE,
94    /// Randomly choose between HID and HIE with equal probability.
95    Random,
96    /// Analyze hydrogen-bond networks to select the tautomer most likely to form
97    /// favorable interactions with nearby acceptors.
98    HbNetwork,
99}
100
101/// Adds hydrogens to all standard residues in-place, updating protonation states when needed.
102///
103/// This function implements a multi-phase pipeline:
104///
105/// 1. **Disulfide detection** — Identifies CYS pairs forming S-S bonds and relabels to CYX.
106/// 2. **Non-HIS protonation** — Applies pKa-based titration to ASP, GLU, LYS, ARG, CYS, TYR
107///    (only when `target_ph` is specified).
108/// 3. **HIS protonation** — Determines HIS state via pH thresholds, salt bridge detection,
109///    and tautomer strategy.
110/// 4. **Hydrogen construction** — Builds hydrogens according to template geometry and
111///    terminal-specific rules.
112///
113/// # Arguments
114///
115/// * `structure` - Mutable structure whose residues will be protonated and hydrated.
116/// * `config` - Hydrogenation configuration controlling pH, strategy, and options.
117///
118/// # Returns
119///
120/// `Ok(())` when hydrogenation succeeds.
121///
122/// # Errors
123///
124/// Returns [`Error::MissingInternalTemplate`] when no template is found or
125/// [`Error::IncompleteResidueForHydro`] when required anchor atoms are missing.
126pub 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
180/// Applies pH-based protonation to all non-HIS titratable residues.
181///
182/// CYX (disulfide-bonded cysteine) is never modified.
183///
184/// # Arguments
185///
186/// * `structure` - Mutable structure whose residues will be protonated.
187/// * `ph` - Target pH for protonation decisions.
188fn 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
214/// Builds a spatial grid of carboxylate oxygen atoms for salt bridge detection.
215///
216/// ASH/GLH (protonated carboxyls) are excluded because neutral COOH groups
217/// cannot form ionic salt bridges.
218///
219/// # Arguments
220///
221/// * `structure` - Structure from which to extract carboxylate oxygens.
222/// * `target_ph` - Optional pH used to determine C-terminal protonation.
223///
224/// # Returns
225///
226/// Spatial grid of carboxylate oxygen positions mapped to (chain_idx, residue_idx).
227fn 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                    // ASP⁻ carboxylate oxygens
241                    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                    // GLU⁻ carboxylate oxygens
251                    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                    // C-terminal COO⁻ (only if deprotonated)
261                    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
281/// Determines the protonation state for a histidine residue.
282///
283/// # Decision Tree
284///
285/// 1. **pH < 6.0** → HIP (doubly protonated, +1 charge)
286/// 2. **No pH AND no salt bridge detection** → `None` (preserve user-defined name)
287/// 3. **Salt bridge detected** → HIP
288/// 4. **No pH** → `None` (salt bridge didn't trigger, preserve name)
289/// 5. **pH ≥ 6.0, no salt bridge** → Apply HisStrategy (HID/HIE)
290///
291/// # Arguments
292///
293/// * `residue` - Histidine residue to evaluate.
294/// * `config` - Hydrogenation configuration.
295/// * `acceptor_grid` - Optional spatial grid of hydrogen bond acceptors.
296/// * `carboxylate_grid` - Optional spatial grid of carboxylate oxygens.
297/// * `self_indices` - Tuple of (chain_idx, residue_idx) for the current residue.
298///
299/// # Returns
300///
301/// `Some(new_name)` when the residue should be renamed, `None` to preserve current name.
302fn 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
336/// Detects if a histidine forms a salt bridge with nearby carboxylate groups.
337///
338/// Checks if either ND1 or NE2 nitrogen is within [`SALT_BRIDGE_DISTANCE`] of
339/// any carboxylate oxygen in the grid.
340///
341/// # Arguments
342///
343/// * `residue` - Histidine residue to evaluate.
344/// * `carboxylate_grid` - Spatial grid of carboxylate oxygen positions.
345/// * `self_indices` - Tuple of (chain_idx, residue_idx) for the current residue.
346///
347/// # Returns
348///
349/// `true` if a salt bridge is detected, `false` otherwise.
350fn his_forms_salt_bridge(
351    residue: &Residue,
352    carboxylate_grid: &Grid<(usize, usize)>,
353    self_indices: (usize, usize),
354) -> bool {
355    // Check ND1
356    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    // Check NE2
368    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
382/// Selects a neutral histidine tautomer using the configured strategy.
383///
384/// # Arguments
385///
386/// * `residue` - Histidine residue to evaluate.
387/// * `strategy` - Strategy for selecting the tautomer.
388/// * `acceptor_grid` - Optional spatial grid of hydrogen bond acceptors.
389/// * `self_indices` - Tuple of (chain_idx, residue_idx) for the current residue.
390///
391/// # Returns
392///
393/// `"HID"` or `"HIE"` depending on the selected tautomer.
394fn 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
415/// Determines the best histidine tautomer by inspecting nearby hydrogen-bond acceptors.
416///
417/// Computes hypothetical H-bond scores for both ND1 (HID) and NE2 (HIE) protonation
418/// and returns the tautomer with the higher score.
419///
420/// # Arguments
421///
422/// * `residue` - Histidine residue to evaluate.
423/// * `acceptor_grid` - Optional spatial grid of hydrogen bond acceptors.
424/// * `self_indices` - Tuple of (chain_idx, residue_idx) for the current residue.
425///
426/// # Returns
427///
428/// `"HID"` or `"HIE"` depending on which tautomer has a better H-bonding score.
429fn 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
449/// Calculates a geometric score for a potential hydrogen bond from an sp² nitrogen.
450///
451/// The score considers both distance (< 2.7 Å) and angle (> 90°) criteria for
452/// valid hydrogen bonds.
453///
454/// # Arguments
455///
456/// * `residue` - Histidine residue containing the nitrogen.
457/// * `n_name` - Name of the nitrogen atom (e.g., "ND1" or "NE2").
458/// * `c1_name` - Name of the first carbon anchor atom.
459/// * `c2_name` - Name of the second carbon anchor atom.
460/// * `grid` - Spatial grid of potential acceptor atoms.
461/// * `self_idx` - Tuple of (chain_idx, residue_idx) for the current residue.
462///
463/// # Returns
464///
465/// Hydrogen bond score as a floating-point value.
466fn 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
518/// Identifies cysteine pairs forming disulfide bonds and renames them to CYX.
519///
520/// # Arguments
521///
522/// * `structure` - Mutable structure to analyze and modify.
523fn 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
582/// Builds a spatial grid of all nitrogen and oxygen atoms for H-bond network analysis.
583///
584/// # Arguments
585///
586/// * `structure` - Structure from which to extract acceptor atoms.
587///
588/// # Returns
589///
590/// Spatial grid of acceptor atom (N, O, F) positions mapped to (chain_idx, residue_idx).
591fn 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
611/// Rebuilds hydrogens for a single residue using template geometry and terminal rules.
612///
613/// # Arguments
614///
615/// * `residue` - Mutable residue to which hydrogens will be added.
616/// * `config` - Hydrogenation configuration controlling pH and options.
617///
618/// # Returns
619///
620/// `Ok(())` when hydrogen construction succeeds.
621///
622/// # Errors
623///
624/// Returns [`Error::MissingInternalTemplate`] when no template is found or
625/// [`Error::IncompleteResidueForHydro`] when required anchor atoms are missing.
626fn 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/// Returns the effective pH used for terminal protonation state decisions.
696#[inline]
697fn effective_terminal_ph(target_ph: Option<f64>) -> f64 {
698    target_ph.unwrap_or(DEFAULT_TERMINAL_PH)
699}
700
701/// Determines if a C-terminus should be considered deprotonated (COO⁻).
702#[inline]
703fn c_terminus_is_deprotonated(target_ph: Option<f64>) -> bool {
704    effective_terminal_ph(target_ph) >= C_TERM_PKA
705}
706
707/// Evaluates whether an N-terminus should be protonated (NH₃⁺).
708#[inline]
709fn n_term_is_protonated(target_ph: Option<f64>) -> bool {
710    effective_terminal_ph(target_ph) <= N_TERM_PKA
711}
712
713/// Evaluates whether a C-terminus should be protonated (COOH).
714#[inline]
715fn c_term_is_protonated(target_ph: Option<f64>) -> bool {
716    effective_terminal_ph(target_ph) < C_TERM_PKA
717}
718
719/// Rebuilds the N-terminal amine hydrogens using tetrahedral sp³ geometry.
720fn 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
780/// Rebuilds the carboxylate proton at the C-terminus when protonated.
781fn 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
829/// Adds the 3'-terminal hydroxyl hydrogen to nucleic acid residues.
830fn 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
862/// Adds the 5'-terminal hydroxyl hydrogen for nucleic acid residues lacking phosphates.
863fn 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
892/// Adds hydrogens to 5'-terminal phosphate groups based on pH.
893///
894/// At physiological pH (≥6.5), the terminal phosphate carries two negative charges
895/// and requires no protons. Below this threshold, one proton is added to OP3.
896fn 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
939/// Aligns template coordinates to residue anchors to predict a hydrogen position.
940fn 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
973/// Builds an sp³ local coordinate frame centered at `center` with primary axis along
974/// `center - attached`.
975fn 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
1000/// Places a hydroxyl hydrogen using sp³ tetrahedral geometry.
1001fn 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
1023/// Computes the optimal rigid transform mapping template anchor points to residue atoms.
1024///
1025/// Uses Kabsch alignment with safeguards for one- and two-point configurations.
1026fn 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}