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