Skip to content

Commit 7494c77

Browse files
authored
Merge pull request #16 from TKanX/bugfix/15-nucleic-acid-5-terminal-repair-and-hydrogenation-mishandles-phosphate-groups
fix(ops): Add Support for 5'-Terminal Nucleic Acid Phosphates
2 parents 74e8200 + 68144a1 commit 7494c77

7 files changed

Lines changed: 448 additions & 17 deletions

File tree

ARCHITECTURE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ flowchart TD
7878
- **FetchTemplate** – pulls `TemplateView` for each residue name, failing early if missing.
7979
- **AlignPairs** – pairs existing heavy atoms with template coordinates for alignment anchors.
8080
- **SVD** – computes rotation/translation via Kabsch/SVD, handling single/dual point fallbacks.
81-
- **SynthesizeAtoms** – recreates missing heavy atoms (and terminal OXT when needed) using transformed template positions.
81+
- **SynthesizeAtoms** – recreates missing heavy atoms (terminal OXT for peptides, OP3 for 5'-phosphorylated nucleic acids) using transformed template positions or tetrahedral geometry.
8282
- **Cleanup** – removes atoms not present in the template to ensure canonical composition.
8383

8484
### 3.3 Hydro Pipeline (`ops::hydro`)
@@ -98,7 +98,7 @@ flowchart TD
9898
- **Protonation** – applies pH-driven heuristics plus HIS strategy selection to decide residue names.
9999
- **StripOldH** – removes existing hydrogens if `remove_existing_h` is enabled.
100100
- **GeometryBuild** – reconstructs hydrogens using template anchors, calling `reconstruct_geometry` and `calculate_transform` internally.
101-
- **TerminalAdjust** – adds terminal hydrogens (N-term, C-term, nucleic 5'/3') respecting protonation states.
101+
- **TerminalAdjust** – adds terminal hydrogens (N-term H1/H2/H3, C-term HOXT, nucleic 5' HO5' or pH-dependent phosphate HOP3, nucleic 3' HO3') respecting protonation states.
102102
- **EndHydro** – structure now contains geometrically sound hydrogens.
103103

104104
### 3.4 Solvate Pipeline (`ops::solvate`)
@@ -136,7 +136,7 @@ flowchart TD
136136
- **PrepareOffsets** – computes atom-index offsets for every residue to map local indexes to global topology indexes.
137137
- **IntraStandard** – connects intra-residue bonds per template definitions, including hydrogen anchors.
138138
- **HeteroTemplates** – injects bonds for hetero residues via user-provided `Template`s or errors if absent.
139-
- **TerminalBonds** – adds special-case bonds for terminal atoms (H1/H2/H3, HOXT, HO5', HO3').
139+
- **TerminalBonds** – adds special-case bonds for terminal atoms (H1/H2/H3, HOXT for peptides; P–OP3, OP3–HOP3, O5'–HO5', O3'–HO3' for nucleic acids).
140140
- **InterResidue** – detects peptide and nucleic linkages by measuring atom distances against cutoffs.
141141
- **DisulfideScan** – adds bonds between cystine sulfurs within the disulfide cutoff.
142142
- **EmitTopology** – produces the final `Topology` pairing the structure with collected bonds.
@@ -179,7 +179,7 @@ flowchart TD
179179
- **Anchor selection** – each template hydrogen lists one or more anchor atoms; missing anchors trigger `IncompleteResidueForHydro` errors to avoid guesswork.
180180
- **Rigid transform**`reconstruct_geometry` retrieves the residue-specific transform (rotation + translation) derived from current heavy atoms and applies it to the template hydrogen coordinate.
181181
- **Randomization** – none is applied for standard hydrogens, ensuring deterministic placement; terminals use evenly spaced tetrahedral vectors sorted by dot product to preserve orientation.
182-
- **Terminal logic** – N-termini place up to three hydrogens arranged around the N–CA axis, while C-termini and nucleic terminals enforce the specific HOXT/HO5'/HO3' connectivity.
182+
- **Terminal logic** – N-termini place up to three hydrogens arranged around the N–CA axis; C-termini add HOXT to OXT; nucleic 5'-terminals either add HO5' (no phosphate) or pH-dependent HOP3 (with phosphate, below pKₐ₂ ≈ 6.5); nucleic 3'-terminals always add HO3'.
183183

184184
### 4.3 Ion Replacement and Degradation Handling
185185

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bio-forge"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
authors = ["Tony Kan <tianchengkan@gmail.com>"]
55
license = "MIT"
66
repository = "https://github.com/TKanX/bio-forge"

MANUAL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Options:
7272
bioforge repair -i cleaned.pdb -o repaired.pdb
7373
```
7474

75-
- Aligns each standard residue to its template and fills in missing heavy atoms, including peptide termini.
75+
- Aligns each standard residue to its template and fills in missing heavy atoms, including peptide termini (OXT) and nucleic acid 5'-terminal phosphate (OP3).
7676
- Ideal immediately after `clean` to ensure the structure is chemically complete before protonation.
7777

7878
### `hydro` – Add hydrogens with titration awareness

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
1. **Load**`io::read_pdb_structure` or `io::read_mmcif_structure` parses coordinates with help from `IoContext` alias resolution.
1616
2. **Clean**`ops::clean_structure` removes waters, ions, hetero residues, or arbitrary residue names via `CleanConfig`.
17-
3. **Repair**`ops::repair_structure` realigns residues to their templates and rebuilds missing heavy atoms (including OXT on C-termini).
17+
3. **Repair**`ops::repair_structure` realigns residues to their templates and rebuilds missing heavy atoms (including OXT on C-termini and OP3 on 5'-phosphorylated nucleic acids).
1818
4. **Hydrogenate**`ops::add_hydrogens` infers protonation states (configurable pH and histidine strategy) and reconstructs hydrogens from template anchors.
1919
5. **Solvate/Ionize**`ops::solvate_structure` creates a periodic box, packs water on a configurable lattice, and swaps molecules for ions to satisfy a target charge.
2020
6. **Topology**`ops::TopologyBuilder` replays template bond definitions, peptide-link detection, nucleic backbone connectivity, and disulfide heuristics to emit a `Topology` object.
@@ -44,7 +44,7 @@ BioForge is also available as a library crate. Add it to your `Cargo.toml` depen
4444

4545
```toml
4646
[dependencies]
47-
bio-forge = "0.2.1"
47+
bio-forge = "0.2.2"
4848
```
4949

5050
#### Example: Preparing a PDB Structure

src/ops/hydro.rs

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const DISULFIDE_SG_THRESHOLD: f64 = 2.2;
2222
const N_TERM_PKA: f64 = 8.0;
2323
/// Henderson–Hasselbalch breakpoint for protonated C-termini.
2424
const C_TERM_PKA: f64 = 3.1;
25+
/// Henderson–Hasselbalch breakpoint for the second dissociation of terminal phosphate.
26+
const PHOSPHATE_PKA2: f64 = 6.5;
2527

2628
/// Parameters controlling hydrogen addition behavior.
2729
///
@@ -370,7 +372,9 @@ fn construct_hydrogens_for_residue(
370372
construct_3_prime_hydrogen(residue)?;
371373
}
372374
ResiduePosition::FivePrime if residue.standard_name.is_some_and(|s| s.is_nucleic()) => {
373-
if !residue.has_atom("P") && residue.has_atom("O5'") {
375+
if residue.has_atom("P") {
376+
construct_5_prime_phosphate_hydrogens(residue, config)?;
377+
} else if residue.has_atom("O5'") {
374378
construct_5_prime_hydrogen(residue)?;
375379
}
376380
}
@@ -654,6 +658,56 @@ fn construct_5_prime_hydrogen(residue: &mut Residue) -> Result<(), Error> {
654658
Ok(())
655659
}
656660

661+
/// Adds hydrogens to 5'-terminal phosphate groups based on pH.
662+
///
663+
/// At physiological pH (≥6.5), the terminal phosphate carries two negative charges and
664+
/// requires no protons. Below this threshold, one proton is added to OP3. The function
665+
/// respects existing hydrogens when `remove_existing_h` is disabled.
666+
///
667+
/// # Arguments
668+
///
669+
/// * `residue` - Nucleic acid residue with a 5'-terminal phosphate.
670+
/// * `config` - Hydrogenation configuration containing pH settings.
671+
///
672+
/// # Returns
673+
///
674+
/// `Ok(())` when hydrogens are appropriately placed or removed.
675+
///
676+
/// # Errors
677+
///
678+
/// Returns [`Error::IncompleteResidueForHydro`] if phosphate atoms are missing.
679+
fn construct_5_prime_phosphate_hydrogens(
680+
residue: &mut Residue,
681+
config: &HydroConfig,
682+
) -> Result<(), Error> {
683+
let ph = config.target_ph.unwrap_or(7.4);
684+
685+
if ph >= PHOSPHATE_PKA2 {
686+
residue.remove_atom("HOP3");
687+
residue.remove_atom("HOP2");
688+
return Ok(());
689+
}
690+
691+
if residue.has_atom("HOP3") {
692+
return Ok(());
693+
}
694+
695+
let op3 = residue
696+
.atom("OP3")
697+
.ok_or_else(|| Error::incomplete_for_hydro(&residue.name, residue.id, "OP3"))?
698+
.pos;
699+
let p = residue
700+
.atom("P")
701+
.ok_or_else(|| Error::incomplete_for_hydro(&residue.name, residue.id, "P"))?
702+
.pos;
703+
704+
let direction = (op3 - p).normalize();
705+
let h_pos = op3 + direction * 0.96;
706+
707+
residue.add_atom(Atom::new("HOP3", Element::H, h_pos));
708+
Ok(())
709+
}
710+
657711
/// Computes the optimal rigid transform mapping template anchor points to residue atoms.
658712
///
659713
/// Uses Kabsch alignment with safeguards for one- and two-point configurations.
@@ -764,6 +818,64 @@ mod tests {
764818
residue
765819
}
766820

821+
fn five_prime_residue_with_phosphate(id: i32) -> Residue {
822+
let template = db::get_template("DA").unwrap();
823+
let mut residue = Residue::new(
824+
id,
825+
None,
826+
"DA",
827+
Some(StandardResidue::DA),
828+
ResidueCategory::Standard,
829+
);
830+
residue.position = ResiduePosition::FivePrime;
831+
for (atom_name, element, pos) in template.heavy_atoms() {
832+
residue.add_atom(Atom::new(atom_name, element, pos));
833+
}
834+
let p_pos = residue.atom("P").unwrap().pos;
835+
let op1_pos = residue.atom("OP1").unwrap().pos;
836+
let op2_pos = residue.atom("OP2").unwrap().pos;
837+
let o5_pos = residue.atom("O5'").unwrap().pos;
838+
let centroid = (op1_pos.coords + op2_pos.coords + o5_pos.coords) / 3.0;
839+
let direction = (p_pos.coords - centroid).normalize();
840+
let op3_pos = p_pos + direction * 1.48;
841+
residue.add_atom(Atom::new("OP3", Element::O, op3_pos));
842+
residue
843+
}
844+
845+
fn five_prime_residue_without_phosphate(id: i32) -> Residue {
846+
let template = db::get_template("DA").unwrap();
847+
let mut residue = Residue::new(
848+
id,
849+
None,
850+
"DA",
851+
Some(StandardResidue::DA),
852+
ResidueCategory::Standard,
853+
);
854+
residue.position = ResiduePosition::FivePrime;
855+
for (atom_name, element, pos) in template.heavy_atoms() {
856+
if !matches!(atom_name, "P" | "OP1" | "OP2") {
857+
residue.add_atom(Atom::new(atom_name, element, pos));
858+
}
859+
}
860+
residue
861+
}
862+
863+
fn three_prime_residue(id: i32) -> Residue {
864+
let template = db::get_template("DA").unwrap();
865+
let mut residue = Residue::new(
866+
id,
867+
None,
868+
"DA",
869+
Some(StandardResidue::DA),
870+
ResidueCategory::Standard,
871+
);
872+
residue.position = ResiduePosition::ThreePrime;
873+
for (atom_name, element, pos) in template.heavy_atoms() {
874+
residue.add_atom(Atom::new(atom_name, element, pos));
875+
}
876+
residue
877+
}
878+
767879
#[test]
768880
fn titratable_templates_exist_in_database() {
769881
let expected = [
@@ -973,4 +1085,66 @@ mod tests {
9731085
assert!(!res1.has_atom("HG"));
9741086
assert!(!res2.has_atom("HG"));
9751087
}
1088+
1089+
#[test]
1090+
fn five_prime_phosphate_deprotonated_at_physiological_ph() {
1091+
let residue = five_prime_residue_with_phosphate(60);
1092+
let mut structure = structure_with_residue(residue);
1093+
1094+
add_hydrogens(&mut structure, &HydroConfig::default()).expect("hydrogenation succeeds");
1095+
1096+
let residue = structure.find_residue("A", 60, None).unwrap();
1097+
assert!(residue.has_atom("OP3"), "OP3 should remain");
1098+
assert!(
1099+
!residue.has_atom("HOP3"),
1100+
"HOP3 should not exist at neutral pH"
1101+
);
1102+
assert!(
1103+
!residue.has_atom("HOP2"),
1104+
"HOP2 should not exist at neutral pH"
1105+
);
1106+
}
1107+
1108+
#[test]
1109+
fn five_prime_phosphate_protonated_below_pka() {
1110+
let residue = five_prime_residue_with_phosphate(61);
1111+
let mut structure = structure_with_residue(residue);
1112+
let mut config = HydroConfig::default();
1113+
config.target_ph = Some(5.5);
1114+
1115+
add_hydrogens(&mut structure, &config).expect("hydrogenation succeeds");
1116+
1117+
let residue = structure.find_residue("A", 61, None).unwrap();
1118+
assert!(residue.has_atom("OP3"), "OP3 should remain");
1119+
assert!(residue.has_atom("HOP3"), "HOP3 should be added below pKa");
1120+
}
1121+
1122+
#[test]
1123+
fn five_prime_without_phosphate_gets_ho5() {
1124+
let residue = five_prime_residue_without_phosphate(62);
1125+
let mut structure = structure_with_residue(residue);
1126+
1127+
add_hydrogens(&mut structure, &HydroConfig::default()).expect("hydrogenation succeeds");
1128+
1129+
let residue = structure.find_residue("A", 62, None).unwrap();
1130+
assert!(
1131+
residue.has_atom("HO5'"),
1132+
"HO5' should be added for 5'-OH terminus"
1133+
);
1134+
assert!(!residue.has_atom("P"), "phosphorus should not exist");
1135+
}
1136+
1137+
#[test]
1138+
fn three_prime_nucleic_gets_ho3() {
1139+
let residue = three_prime_residue(70);
1140+
let mut structure = structure_with_residue(residue);
1141+
1142+
add_hydrogens(&mut structure, &HydroConfig::default()).expect("hydrogenation succeeds");
1143+
1144+
let residue = structure.find_residue("A", 70, None).unwrap();
1145+
assert!(
1146+
residue.has_atom("HO3'"),
1147+
"HO3' should be added for 3' terminal"
1148+
);
1149+
}
9761150
}

0 commit comments

Comments
 (0)