Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.

Commit 8f88c4f

Browse files
authored
Merge pull request #49 from caltechmsc/bugfix/48-placement-workflow-optimizes-fixed-disulfide-bonds-leading-to-invalid-structures
fix(core): Exclude Disulfide-Bonded Cysteines from Optimization
2 parents 022328b + 413d412 commit 8f88c4f

4 files changed

Lines changed: 294 additions & 35 deletions

File tree

crates/scream-core/src/core/models/residue.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ impl ResidueType {
128128
"TRP" => Some(ResidueType::Tryptophan),
129129
"TYR" => Some(ResidueType::Tyrosine),
130130
"ASN" => Some(ResidueType::Asparagine),
131-
"CYS" => Some(ResidueType::Cysteine),
131+
"CYS" | "CYX" => Some(ResidueType::Cysteine),
132132
"GLN" => Some(ResidueType::Glutamine),
133133
"SER" => Some(ResidueType::Serine),
134134
"THR" => Some(ResidueType::Threonine),

crates/scream-core/src/core/models/system.rs

Lines changed: 235 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use super::ids::{AtomId, ChainId, ResidueId};
44
use super::residue::{Residue, ResidueType};
55
use super::topology::{Bond, BondOrder};
66
use slotmap::{SecondaryMap, SlotMap};
7-
use std::collections::HashMap;
7+
use std::collections::{HashMap, HashSet};
8+
9+
const CYSTEINE_SULFUR_GAMMA_ATOM_NAME: &str = "SG";
810

911
/// Represents a complete molecular system with atoms, residues, chains, and bonds.
1012
///
@@ -470,6 +472,68 @@ impl MolecularSystem {
470472
pub fn background_atom_ids(&self) -> Vec<AtomId> {
471473
self.background_atoms().map(|(id, _)| id).collect()
472474
}
475+
476+
/// Detects and returns the residue IDs of all Cysteine residues involved in disulfide bonds.
477+
///
478+
/// A disulfide bond is identified by a covalent bond between the Sulfur-Gamma (SG)
479+
/// atoms of two different Cysteine residues. This method correctly handles both
480+
/// `CYS` and `CYX` residue names by relying on the `ResidueType`.
481+
///
482+
/// # Returns
483+
///
484+
/// A `HashSet<ResidueId>` containing the IDs of all residues participating in
485+
/// any disulfide bond within the system.
486+
pub fn find_disulfide_bonded_residues(&self) -> HashSet<ResidueId> {
487+
let mut bonded_residue_ids = HashSet::new();
488+
489+
// 1. Collect all Cysteine residues and their SG atoms
490+
let cysteine_sg_atoms: HashMap<ResidueId, AtomId> = self
491+
.residues_iter()
492+
.filter_map(|(res_id, residue)| {
493+
if matches!(residue.residue_type, Some(ResidueType::Cysteine)) {
494+
residue
495+
.get_first_atom_id_by_name(CYSTEINE_SULFUR_GAMMA_ATOM_NAME)
496+
.map(|sg_id| (res_id, sg_id))
497+
} else {
498+
None
499+
}
500+
})
501+
.collect();
502+
503+
// If there are fewer than two Cysteines, no disulfide bonds are possible
504+
if cysteine_sg_atoms.len() < 2 {
505+
return bonded_residue_ids;
506+
}
507+
508+
// Create a reverse map from SG AtomId to ResidueId for quick lookups
509+
let sg_atom_to_residue: HashMap<AtomId, ResidueId> = cysteine_sg_atoms
510+
.iter()
511+
.map(|(&res_id, &atom_id)| (atom_id, res_id))
512+
.collect();
513+
514+
// 2. Iterate through the collected SG atoms and check their bonds
515+
for (res_id_a, sg_atom_id_a) in &cysteine_sg_atoms {
516+
if bonded_residue_ids.contains(res_id_a) {
517+
continue;
518+
}
519+
520+
if let Some(neighbors) = self.get_bonded_neighbors(*sg_atom_id_a) {
521+
for &neighbor_atom_id in neighbors {
522+
// 3. Check if the neighbor is also an SG atom of another Cysteine
523+
if let Some(res_id_b) = sg_atom_to_residue.get(&neighbor_atom_id) {
524+
if res_id_a != res_id_b {
525+
// Found a disulfide bond!
526+
bonded_residue_ids.insert(*res_id_a);
527+
bonded_residue_ids.insert(*res_id_b);
528+
break;
529+
}
530+
}
531+
}
532+
}
533+
}
534+
535+
bonded_residue_ids
536+
}
473537
}
474538

475539
#[cfg(test)]
@@ -897,4 +961,174 @@ mod tests {
897961
assert!(background_ids.contains(id_map.get("UNKNOWN").unwrap()));
898962
}
899963
}
964+
965+
mod disulfide_bond_detection {
966+
use super::*;
967+
use crate::core::models::topology::BondOrder;
968+
use nalgebra::Point3;
969+
970+
fn create_disulfide_test_system()
971+
-> (MolecularSystem, ResidueId, ResidueId, ResidueId, ResidueId) {
972+
let mut system = MolecularSystem::new();
973+
let chain_a_id = system.add_chain('A', ChainType::Protein);
974+
975+
let cys1_id = system
976+
.add_residue(chain_a_id, 1, "CYS", Some(ResidueType::Cysteine))
977+
.unwrap();
978+
let cys1_sg_id = system
979+
.add_atom_to_residue(
980+
cys1_id,
981+
Atom::new(
982+
CYSTEINE_SULFUR_GAMMA_ATOM_NAME,
983+
cys1_id,
984+
Point3::new(0.0, 0.0, 0.0),
985+
),
986+
)
987+
.unwrap();
988+
989+
let cys2_id = system
990+
.add_residue(chain_a_id, 2, "CYX", Some(ResidueType::Cysteine))
991+
.unwrap();
992+
let cys2_sg_id = system
993+
.add_atom_to_residue(
994+
cys2_id,
995+
Atom::new(
996+
CYSTEINE_SULFUR_GAMMA_ATOM_NAME,
997+
cys2_id,
998+
Point3::new(2.0, 0.0, 0.0),
999+
),
1000+
)
1001+
.unwrap();
1002+
1003+
let cys3_id = system
1004+
.add_residue(chain_a_id, 3, "CYS", Some(ResidueType::Cysteine))
1005+
.unwrap();
1006+
system
1007+
.add_atom_to_residue(
1008+
cys3_id,
1009+
Atom::new(
1010+
CYSTEINE_SULFUR_GAMMA_ATOM_NAME,
1011+
cys3_id,
1012+
Point3::new(10.0, 0.0, 0.0),
1013+
),
1014+
)
1015+
.unwrap();
1016+
1017+
let ala4_id = system
1018+
.add_residue(chain_a_id, 4, "ALA", Some(ResidueType::Alanine))
1019+
.unwrap();
1020+
system
1021+
.add_atom_to_residue(
1022+
ala4_id,
1023+
Atom::new("CB", ala4_id, Point3::new(12.0, 0.0, 0.0)),
1024+
)
1025+
.unwrap();
1026+
1027+
system
1028+
.add_bond(cys1_sg_id, cys2_sg_id, BondOrder::Single)
1029+
.unwrap();
1030+
1031+
(system, cys1_id, cys2_id, cys3_id, ala4_id)
1032+
}
1033+
1034+
#[test]
1035+
fn find_disulfide_bonded_residues_identifies_correct_pair() {
1036+
let (system, cys1_id, cys2_id, cys3_id, ala4_id) = create_disulfide_test_system();
1037+
1038+
let bonded_residues = system.find_disulfide_bonded_residues();
1039+
1040+
assert_eq!(bonded_residues.len(), 2);
1041+
assert!(bonded_residues.contains(&cys1_id));
1042+
assert!(bonded_residues.contains(&cys2_id));
1043+
assert!(!bonded_residues.contains(&cys3_id));
1044+
assert!(!bonded_residues.contains(&ala4_id));
1045+
}
1046+
1047+
#[test]
1048+
fn find_disulfide_bonded_residues_returns_empty_for_no_bonds() {
1049+
let (mut system, cys1_id, cys2_id, _, _) = create_disulfide_test_system();
1050+
let cys1_sg_id = system
1051+
.residue(cys1_id)
1052+
.unwrap()
1053+
.get_first_atom_id_by_name(CYSTEINE_SULFUR_GAMMA_ATOM_NAME)
1054+
.unwrap();
1055+
let cys2_sg_id = system
1056+
.residue(cys2_id)
1057+
.unwrap()
1058+
.get_first_atom_id_by_name(CYSTEINE_SULFUR_GAMMA_ATOM_NAME)
1059+
.unwrap();
1060+
1061+
system
1062+
.bonds
1063+
.retain(|bond| !(bond.contains(cys1_sg_id) && bond.contains(cys2_sg_id)));
1064+
system.bond_adjacency.get_mut(cys1_sg_id).unwrap().clear();
1065+
system.bond_adjacency.get_mut(cys2_sg_id).unwrap().clear();
1066+
1067+
let bonded_residues = system.find_disulfide_bonded_residues();
1068+
assert!(
1069+
bonded_residues.is_empty(),
1070+
"Expected no bonded residues after removing the bond"
1071+
);
1072+
}
1073+
1074+
#[test]
1075+
fn find_disulfide_bonded_residues_returns_empty_for_no_cysteines() {
1076+
let mut system = MolecularSystem::new();
1077+
let chain_a_id = system.add_chain('A', ChainType::Protein);
1078+
system
1079+
.add_residue(chain_a_id, 4, "ALA", Some(ResidueType::Alanine))
1080+
.unwrap();
1081+
1082+
let bonded_residues = system.find_disulfide_bonded_residues();
1083+
assert!(bonded_residues.is_empty());
1084+
}
1085+
1086+
#[test]
1087+
fn find_disulfide_bonded_residues_handles_multiple_bonds() {
1088+
let (mut system, cys1_id, cys2_id, cys3_id, ala4_id) = create_disulfide_test_system();
1089+
1090+
let chain_b_id = system.add_chain('B', ChainType::Protein);
1091+
let cys10_id = system
1092+
.add_residue(chain_b_id, 10, "CYS", Some(ResidueType::Cysteine))
1093+
.unwrap();
1094+
let cys10_sg_id = system
1095+
.add_atom_to_residue(
1096+
cys10_id,
1097+
Atom::new(
1098+
CYSTEINE_SULFUR_GAMMA_ATOM_NAME,
1099+
cys10_id,
1100+
Point3::new(50.0, 0.0, 0.0),
1101+
),
1102+
)
1103+
.unwrap();
1104+
1105+
let cys20_id = system
1106+
.add_residue(chain_b_id, 20, "CYS", Some(ResidueType::Cysteine))
1107+
.unwrap();
1108+
let cys20_sg_id = system
1109+
.add_atom_to_residue(
1110+
cys20_id,
1111+
Atom::new(
1112+
CYSTEINE_SULFUR_GAMMA_ATOM_NAME,
1113+
cys20_id,
1114+
Point3::new(52.0, 0.0, 0.0),
1115+
),
1116+
)
1117+
.unwrap();
1118+
1119+
system
1120+
.add_bond(cys10_sg_id, cys20_sg_id, BondOrder::Single)
1121+
.unwrap();
1122+
1123+
let bonded_residues = system.find_disulfide_bonded_residues();
1124+
1125+
assert_eq!(bonded_residues.len(), 4);
1126+
assert!(bonded_residues.contains(&cys1_id));
1127+
assert!(bonded_residues.contains(&cys2_id));
1128+
assert!(!bonded_residues.contains(&cys3_id));
1129+
assert!(!bonded_residues.contains(&ala4_id));
1130+
assert!(bonded_residues.contains(&cys10_id));
1131+
assert!(bonded_residues.contains(&cys20_id));
1132+
}
1133+
}
9001134
}

crates/scream-core/src/engine/energy_grid.rs

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ use crate::core::forcefield::term::EnergyTerm;
88
use crate::core::models::atom::AtomRole;
99
use crate::core::models::ids::ResidueId;
1010
use crate::core::models::system::MolecularSystem;
11-
use itertools::Itertools;
1211
use std::collections::{HashMap, HashSet};
1312
use tracing::{info, trace};
1413

@@ -105,42 +104,43 @@ impl EnergyGrid {
105104

106105
let scorer = Scorer::new(system, forcefield);
107106

108-
for pair in active_residues.iter().combinations(2) {
109-
let res_a_id = *pair[0];
110-
let res_b_id = *pair[1];
107+
let active_residue_vec: Vec<_> = active_residues.iter().cloned().collect();
108+
for i in 0..active_residue_vec.len() {
109+
for j in (i + 1)..active_residue_vec.len() {
110+
let res_a_id = active_residue_vec[i];
111+
let res_b_id = active_residue_vec[j];
111112

112-
let atoms_a = collect_active_sidechain_atoms(system, &HashSet::from([res_a_id]));
113-
let atoms_b = collect_active_sidechain_atoms(system, &HashSet::from([res_b_id]));
113+
let atoms_a = collect_active_sidechain_atoms(system, &HashSet::from([res_a_id]));
114+
let atoms_b = collect_active_sidechain_atoms(system, &HashSet::from([res_b_id]));
114115

115-
let atoms_a_slice = atoms_a
116-
.get(&res_a_id)
117-
.map_or([].as_slice(), |v| v.as_slice());
118-
let atoms_b_slice = atoms_b
119-
.get(&res_b_id)
120-
.map_or([].as_slice(), |v| v.as_slice());
116+
let atoms_a_slice = atoms_a
117+
.get(&res_a_id)
118+
.map_or([].as_slice(), |v| v.as_slice());
119+
let atoms_b_slice = atoms_b
120+
.get(&res_b_id)
121+
.map_or([].as_slice(), |v| v.as_slice());
121122

122-
if atoms_a_slice.is_empty() || atoms_b_slice.is_empty() {
123-
continue;
124-
}
123+
if atoms_a_slice.is_empty() || atoms_b_slice.is_empty() {
124+
continue;
125+
}
125126

126-
let interaction = scorer.score_interaction(atoms_a_slice, atoms_b_slice)?;
127+
let interaction = scorer.score_interaction(atoms_a_slice, atoms_b_slice)?;
127128

128-
let key = if res_a_id < res_b_id {
129-
(res_a_id, res_b_id)
130-
} else {
131-
(res_b_id, res_a_id)
132-
};
133-
pair_interactions.insert(key, interaction);
129+
let key = (res_a_id, res_b_id);
130+
pair_interactions.insert(key, interaction);
131+
132+
*total_residue_interactions.get_mut(&res_a_id).unwrap() += interaction;
133+
*total_residue_interactions.get_mut(&res_b_id).unwrap() += interaction;
134+
}
135+
}
134136

135-
*total_residue_interactions.get_mut(&res_a_id).unwrap() += interaction;
136-
*total_residue_interactions.get_mut(&res_b_id).unwrap() += interaction;
137+
for term in total_residue_interactions.values_mut() {
138+
*term = *term * 0.5;
137139
}
138140

139-
// The total interaction energy is double-counted in the sum above, so divide by 2
140-
let total_interaction_energy = total_residue_interactions
141+
let total_interaction_energy = pair_interactions
141142
.values()
142-
.fold(EnergyTerm::default(), |acc, term| acc + *term)
143-
* 0.5;
143+
.fold(EnergyTerm::default(), |acc, term| acc + *term);
144144

145145
let mut current_el_energies = HashMap::with_capacity(active_residues.len());
146146
let mut total_el_energy = EnergyTerm::default();

0 commit comments

Comments
 (0)