Skip to main content

cyanea_struct/
secondary.rs

1//! Secondary structure assignment using DSSP-like algorithms.
2//!
3//! Provides both a simplified distance-based DSSP (the original
4//! [`assign_secondary_structure`]) and a full hydrogen-bond energy-based DSSP
5//! ([`dssp`]) that classifies residues into the standard 8-state scheme.
6
7use cyanea_core::{CyaneaError, Result, Summarizable};
8
9use crate::geometry::{angle_points, dihedral_points};
10use crate::types::{Chain, Point3D, Residue};
11
12#[allow(unused_imports)]
13use alloc::format;
14use alloc::string::String;
15use alloc::vec;
16use alloc::vec::Vec;
17
18/// Secondary structure classification for a single residue (simplified 4-state).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub enum SecondaryStructure {
22    Helix,
23    Sheet,
24    Turn,
25    Coil,
26}
27
28impl SecondaryStructure {
29    /// Single-character DSSP code.
30    pub fn code(&self) -> char {
31        match self {
32            SecondaryStructure::Helix => 'H',
33            SecondaryStructure::Sheet => 'E',
34            SecondaryStructure::Turn => 'T',
35            SecondaryStructure::Coil => 'C',
36        }
37    }
38}
39
40/// Full 8-state DSSP classification.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub enum DsspState {
44    /// Alpha-helix (i -> i+4 H-bond pattern, >= 4 consecutive).
45    H,
46    /// 3_10-helix (i -> i+3 H-bond pattern).
47    G,
48    /// Pi-helix (i -> i+5 H-bond pattern).
49    I,
50    /// Extended strand in beta-sheet.
51    E,
52    /// Isolated beta-bridge residue.
53    B,
54    /// Hydrogen-bonded turn.
55    T,
56    /// Bend (CA angle > 70 degrees).
57    S,
58    /// Coil / loop (none of the above).
59    C,
60}
61
62impl DsspState {
63    /// Single-character DSSP code.
64    pub fn code(&self) -> char {
65        match self {
66            DsspState::H => 'H',
67            DsspState::G => 'G',
68            DsspState::I => 'I',
69            DsspState::E => 'E',
70            DsspState::B => 'B',
71            DsspState::T => 'T',
72            DsspState::S => 'S',
73            DsspState::C => 'C',
74        }
75    }
76
77    /// Convert to the simplified 4-state classification.
78    pub fn to_simplified(&self) -> SecondaryStructure {
79        match self {
80            DsspState::H | DsspState::G | DsspState::I => SecondaryStructure::Helix,
81            DsspState::E | DsspState::B => SecondaryStructure::Sheet,
82            DsspState::T | DsspState::S => SecondaryStructure::Turn,
83            DsspState::C => SecondaryStructure::Coil,
84        }
85    }
86}
87
88/// Full DSSP assignment result for a chain.
89#[derive(Debug, Clone)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub struct DsspAssignment {
92    /// Chain identifier.
93    pub chain_id: char,
94    /// One 8-state assignment per residue.
95    pub states: Vec<DsspState>,
96}
97
98impl DsspAssignment {
99    /// Convert to a string of single-character DSSP codes.
100    pub fn to_string_codes(&self) -> String {
101        self.states.iter().map(|s| s.code()).collect()
102    }
103
104    /// Convert to the simplified 4-state assignment.
105    pub fn to_simplified(&self) -> SecondaryStructureAssignment {
106        SecondaryStructureAssignment {
107            chain_id: self.chain_id,
108            assignments: self.states.iter().map(|s| s.to_simplified()).collect(),
109        }
110    }
111
112    /// Count of each DSSP state.
113    pub fn counts(&self) -> DsspCounts {
114        let mut counts = DsspCounts::default();
115        for s in &self.states {
116            match s {
117                DsspState::H => counts.h += 1,
118                DsspState::G => counts.g += 1,
119                DsspState::I => counts.i += 1,
120                DsspState::E => counts.e += 1,
121                DsspState::B => counts.b += 1,
122                DsspState::T => counts.t += 1,
123                DsspState::S => counts.s += 1,
124                DsspState::C => counts.c += 1,
125            }
126        }
127        counts
128    }
129}
130
131impl Summarizable for DsspAssignment {
132    fn summary(&self) -> String {
133        let c = self.counts();
134        format!(
135            "Chain {} DSSP: {} residue(s) — H:{} G:{} I:{} E:{} B:{} T:{} S:{} C:{}",
136            self.chain_id,
137            self.states.len(),
138            c.h, c.g, c.i, c.e, c.b, c.t, c.s, c.c,
139        )
140    }
141}
142
143/// Counts of each DSSP state.
144#[derive(Debug, Clone, Default)]
145pub struct DsspCounts {
146    pub h: usize,
147    pub g: usize,
148    pub i: usize,
149    pub e: usize,
150    pub b: usize,
151    pub t: usize,
152    pub s: usize,
153    pub c: usize,
154}
155
156/// Secondary structure assignment for a chain.
157#[derive(Debug, Clone)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159pub struct SecondaryStructureAssignment {
160    /// Chain identifier.
161    pub chain_id: char,
162    /// One assignment per residue.
163    pub assignments: Vec<SecondaryStructure>,
164}
165
166impl SecondaryStructureAssignment {
167    /// Count of each secondary structure type.
168    pub fn counts(&self) -> (usize, usize, usize, usize) {
169        let mut h = 0;
170        let mut e = 0;
171        let mut t = 0;
172        let mut c = 0;
173        for ss in &self.assignments {
174            match ss {
175                SecondaryStructure::Helix => h += 1,
176                SecondaryStructure::Sheet => e += 1,
177                SecondaryStructure::Turn => t += 1,
178                SecondaryStructure::Coil => c += 1,
179            }
180        }
181        (h, e, t, c)
182    }
183
184    /// Fraction of residues in helix.
185    pub fn helix_fraction(&self) -> f64 {
186        if self.assignments.is_empty() {
187            return 0.0;
188        }
189        let (h, _, _, _) = self.counts();
190        h as f64 / self.assignments.len() as f64
191    }
192
193    /// Fraction of residues in sheet.
194    pub fn sheet_fraction(&self) -> f64 {
195        if self.assignments.is_empty() {
196            return 0.0;
197        }
198        let (_, e, _, _) = self.counts();
199        e as f64 / self.assignments.len() as f64
200    }
201}
202
203impl Summarizable for SecondaryStructureAssignment {
204    fn summary(&self) -> String {
205        let (h, e, t, c) = self.counts();
206        format!(
207            "Chain {} SS: {} residue(s) — H:{} E:{} T:{} C:{}",
208            self.chain_id,
209            self.assignments.len(),
210            h,
211            e,
212            t,
213            c,
214        )
215    }
216}
217
218/// Assign secondary structure to each residue in a chain.
219///
220/// Uses a simplified DSSP-like algorithm:
221/// - **Helix**: backbone O(i) to N(i+4) distance < 3.5 Å for at least 3 consecutive residues
222/// - **Turn**: backbone O(i) to N(i+3) distance < 3.5 Å
223/// - **Sheet**: detected via antiparallel beta-bridge patterns (O(i)→N(j) and O(j)→N(i) both < 3.5 Å with |i-j| > 4)
224/// - **Coil**: everything else
225pub fn assign_secondary_structure(chain: &Chain) -> Result<SecondaryStructureAssignment> {
226    let n = chain.residues.len();
227    if n == 0 {
228        return Err(CyaneaError::InvalidInput(
229            "cannot assign SS to empty chain".into(),
230        ));
231    }
232
233    let mut assignments = vec![SecondaryStructure::Coil; n];
234
235    // Precompute backbone atom positions (N, O) for each residue
236    let backbone: Vec<Option<(Point3D, Point3D)>> = chain
237        .residues
238        .iter()
239        .map(|r| {
240            let n_atom = r.get_atom("N")?;
241            let o_atom = r.get_atom("O")?;
242            Some((n_atom.coords, o_atom.coords))
243        })
244        .collect();
245
246    let hbond_cutoff = 3.5;
247
248    // Check i→i+4 H-bonds (helix pattern)
249    let mut helix_hbond = vec![false; n];
250    for i in 0..n.saturating_sub(4) {
251        if let (Some((_, o_i)), Some((n_i4, _))) = (&backbone[i], &backbone[i + 4]) {
252            if o_i.distance_to(n_i4) < hbond_cutoff {
253                helix_hbond[i] = true;
254            }
255        }
256    }
257
258    // Helix: 3+ consecutive i→i+4 H-bonds
259    for i in 0..n.saturating_sub(6) {
260        if helix_hbond[i] && helix_hbond[i + 1] && helix_hbond[i + 2] {
261            // Mark residues i through i+6 (the full helix span) as Helix
262            for j in i..=(i + 5).min(n - 1) {
263                assignments[j] = SecondaryStructure::Helix;
264            }
265        }
266    }
267
268    // Check antiparallel beta-bridges (sheet pattern)
269    for i in 0..n {
270        for j in (i + 5)..n {
271            if let (Some((n_i, o_i)), Some((n_j, o_j))) = (&backbone[i], &backbone[j]) {
272                let oi_nj = o_i.distance_to(n_j);
273                let oj_ni = o_j.distance_to(n_i);
274                if oi_nj < hbond_cutoff && oj_ni < hbond_cutoff {
275                    if assignments[i] == SecondaryStructure::Coil {
276                        assignments[i] = SecondaryStructure::Sheet;
277                    }
278                    if assignments[j] == SecondaryStructure::Coil {
279                        assignments[j] = SecondaryStructure::Sheet;
280                    }
281                }
282            }
283        }
284    }
285
286    // Check i→i+3 (turn pattern)
287    for i in 0..n.saturating_sub(3) {
288        if let (Some((_, o_i)), Some((n_i3, _))) = (&backbone[i], &backbone[i + 3]) {
289            if o_i.distance_to(n_i3) < hbond_cutoff {
290                // Only assign turn if not already helix/sheet
291                for j in i..=(i + 3).min(n - 1) {
292                    if assignments[j] == SecondaryStructure::Coil {
293                        assignments[j] = SecondaryStructure::Turn;
294                    }
295                }
296            }
297        }
298    }
299
300    Ok(SecondaryStructureAssignment {
301        chain_id: chain.id,
302        assignments,
303    })
304}
305
306/// Full DSSP secondary structure assignment using hydrogen-bond energies.
307///
308/// Implements the Kabsch & Sander (1983) algorithm:
309///
310/// 1. Identify backbone atoms (N, CA, C, O) for each residue
311/// 2. Calculate NH->CO hydrogen bond energies using the electrostatic model:
312///    `E = 0.084 * (1/r_ON + 1/r_CH - 1/r_OH - 1/r_CN) * 332` kcal/mol
313///    A bond exists if E < -0.5 kcal/mol
314/// 3. Classify into 8 states based on H-bond patterns:
315///    - H: alpha-helix (i->i+4 pattern, >= 4 consecutive)
316///    - G: 3_10-helix (i->i+3 pattern)
317///    - I: pi-helix (i->i+5 pattern)
318///    - E: extended strand (parallel or antiparallel H-bonds to distant residues)
319///    - B: isolated beta-bridge
320///    - T: hydrogen-bonded turn
321///    - S: bend (CA angle > 70 degrees)
322///    - C: coil (everything else)
323///
324/// # Errors
325///
326/// Returns an error if the chain is empty.
327pub fn dssp(chain: &Chain) -> Result<DsspAssignment> {
328    let n = chain.residues.len();
329    if n == 0 {
330        return Err(CyaneaError::InvalidInput(
331            "cannot assign DSSP to empty chain".into(),
332        ));
333    }
334
335    // Step 1: Extract backbone atom positions
336    let backbone = extract_backbone(chain);
337
338    // Step 2: Compute hydrogen bond energy matrix
339    // hbond_energy[i][j] stores the energy of the NH(i) -> CO(j) hydrogen bond
340    let hbond = compute_hbond_matrix(&backbone, n);
341
342    let mut states = vec![DsspState::C; n];
343
344    // Step 3a: Identify helix patterns (must be done before bridges)
345    // Check n-turns of size 3, 4, 5
346    let turn3 = find_turns(&hbond, n, 3);
347    let turn4 = find_turns(&hbond, n, 4);
348    let turn5 = find_turns(&hbond, n, 5);
349
350    // Alpha-helix (H): 4+ consecutive i->i+4 H-bonds
351    assign_helix(&turn4, n, 4, DsspState::H, &mut states);
352    // 3_10-helix (G): 3+ consecutive i->i+3 H-bonds
353    assign_helix(&turn3, n, 3, DsspState::G, &mut states);
354    // Pi-helix (I): 5+ consecutive i->i+5 H-bonds
355    assign_helix(&turn5, n, 5, DsspState::I, &mut states);
356
357    // Step 3b: Identify beta-bridges and strands
358    let bridges = find_bridges(&hbond, n);
359    assign_bridges_and_strands(&bridges, n, &mut states);
360
361    // Step 3c: Assign turns (only to residues not already assigned helix/strand)
362    assign_turns(&turn3, &turn4, &turn5, n, &mut states);
363
364    // Step 3d: Assign bends
365    assign_bends(chain, &mut states);
366
367    Ok(DsspAssignment {
368        chain_id: chain.id,
369        states,
370    })
371}
372
373/// Backbone atom coordinates for a single residue.
374#[allow(dead_code)]
375struct BackboneAtoms {
376    n: Option<Point3D>,
377    ca: Option<Point3D>,
378    c: Option<Point3D>,
379    o: Option<Point3D>,
380}
381
382/// Extract backbone atom positions for all residues.
383fn extract_backbone(chain: &Chain) -> Vec<BackboneAtoms> {
384    chain
385        .residues
386        .iter()
387        .map(|r| BackboneAtoms {
388            n: r.get_atom("N").map(|a| a.coords),
389            ca: r.get_atom("CA").map(|a| a.coords),
390            c: r.get_atom("C").map(|a| a.coords),
391            o: r.get_atom("O").map(|a| a.coords),
392        })
393        .collect()
394}
395
396/// Compute the NH(i) -> CO(j) hydrogen bond energy in kcal/mol.
397///
398/// Uses the Kabsch-Sander electrostatic model:
399/// E = 0.084 * (1/r_ON + 1/r_CH - 1/r_OH - 1/r_CN) * 332
400///
401/// The H atom position is estimated as being along the N-C_prev bond direction,
402/// 1.0 A from N.
403fn hbond_energy(donor: &BackboneAtoms, acceptor: &BackboneAtoms, prev_c: Option<&Point3D>) -> f64 {
404    // Need: donor N and H (estimated), acceptor C and O
405    let n_pos = match donor.n {
406        Some(p) => p,
407        None => return 0.0,
408    };
409    let o_pos = match acceptor.o {
410        Some(p) => p,
411        None => return 0.0,
412    };
413    let c_pos = match acceptor.c {
414        Some(p) => p,
415        None => return 0.0,
416    };
417
418    // Estimate H position: H is on the N, opposite to the C(i-1)->N direction
419    // H = N + (N - C_prev).normalize() * 1.0
420    let h_pos = match prev_c {
421        Some(c_prev) => {
422            let nc_dir = n_pos.sub(c_prev).normalize();
423            n_pos.add(&nc_dir.scale(1.0))
424        }
425        None => return 0.0, // Can't estimate H for first residue
426    };
427
428    let r_on = n_pos.distance_to(&o_pos);
429    let r_ch = h_pos.distance_to(&c_pos);
430    let r_oh = h_pos.distance_to(&o_pos);
431    let r_cn = n_pos.distance_to(&c_pos);
432
433    // Avoid division by zero
434    if r_on < 0.5 || r_ch < 0.5 || r_oh < 0.5 || r_cn < 0.5 {
435        return 0.0;
436    }
437
438    0.084 * (1.0 / r_on + 1.0 / r_ch - 1.0 / r_oh - 1.0 / r_cn) * 332.0
439}
440
441/// Compute the hydrogen bond energy matrix.
442/// Returns a flat Vec where hbond[i * n + j] = energy of NH(i) -> CO(j).
443/// Only stores energies below the threshold.
444fn compute_hbond_matrix(backbone: &[BackboneAtoms], n: usize) -> Vec<f64> {
445    let mut hbond = vec![0.0_f64; n * n];
446    let hbond_threshold = -0.5;
447
448    for i in 1..n {
449        // Donor is residue i (NH), need C of residue i-1 for H estimation
450        let prev_c = backbone[i - 1].c.as_ref();
451
452        for j in 0..n {
453            if i == j {
454                continue;
455            }
456            // Skip if donor and acceptor are too close in sequence for meaningful bond
457            // (i, i+1) and (i, i-1) are peptide bonds, not H-bonds
458            if (i as isize - j as isize).unsigned_abs() < 2 {
459                continue;
460            }
461
462            let energy = hbond_energy(&backbone[i], &backbone[j], prev_c);
463            if energy < hbond_threshold {
464                hbond[i * n + j] = energy;
465            }
466        }
467    }
468
469    hbond
470}
471
472/// Find n-turns: residue i has a turn of size `turn_size` if NH(i+turn_size) -> CO(i) H-bond exists.
473fn find_turns(hbond: &[f64], n: usize, turn_size: usize) -> Vec<bool> {
474    let mut turns = vec![false; n];
475    for i in 0..n.saturating_sub(turn_size) {
476        let donor = i + turn_size;
477        if donor < n && hbond[donor * n + i] < -0.5 {
478            turns[i] = true;
479        }
480    }
481    turns
482}
483
484/// Assign helix states based on consecutive turns.
485fn assign_helix(
486    turns: &[bool],
487    n: usize,
488    turn_size: usize,
489    state: DsspState,
490    states: &mut [DsspState],
491) {
492    // A helix requires at least 2 consecutive turns of the same type
493    // (meaning the pattern i->i+k and (i+1)->(i+1+k) both exist)
494    // The helix then spans from i+1 to i+turn_size for each such pair.
495    let min_consecutive = 2;
496    let mut consecutive = 0;
497
498    for i in 0..n.saturating_sub(turn_size) {
499        if turns[i] {
500            consecutive += 1;
501            if consecutive >= min_consecutive {
502                // Mark the helix residues for this span
503                // The helix starts at i - (consecutive - min_consecutive) + 1
504                // and extends to i + turn_size
505                let start = (i + 2).saturating_sub(consecutive);
506                let end = (i + turn_size).min(n - 1);
507                for j in start..=end {
508                    // Only assign if current state is Coil or same type
509                    // H takes precedence over G, G over I
510                    if states[j] == DsspState::C
511                        || (state == DsspState::H && (states[j] == DsspState::G || states[j] == DsspState::I))
512                        || (state == DsspState::G && states[j] == DsspState::I)
513                    {
514                        states[j] = state;
515                    }
516                }
517            }
518        } else {
519            consecutive = 0;
520        }
521    }
522}
523
524/// A beta-bridge between two residues.
525#[derive(Debug, Clone, Copy)]
526#[allow(dead_code)]
527struct BetaBridge {
528    i: usize,
529    j: usize,
530    is_parallel: bool,
531}
532
533/// Find all beta-bridges in the chain.
534fn find_bridges(hbond: &[f64], n: usize) -> Vec<BetaBridge> {
535    let mut bridges = Vec::new();
536    let threshold = -0.5;
537
538    for i in 1..n.saturating_sub(1) {
539        for j in (i + 3)..n.saturating_sub(1) {
540            // Antiparallel bridge: NH(i)->CO(j) and NH(j)->CO(i)
541            let anti = hbond[i * n + j] < threshold && hbond[j * n + i] < threshold;
542            // Alternative antiparallel: NH(i)->CO(j-1) and NH(j+1)->CO(i)
543            // or NH(i+1)->CO(j) and NH(j)->CO(i-1)
544            let anti_alt1 = j > 0
545                && i + 1 < n
546                && hbond[i * n + (j - 1)] < threshold
547                && (j + 1) < n
548                && hbond[(j + 1) * n + i] < threshold;
549            let anti_alt2 = i > 0
550                && (i + 1) < n
551                && hbond[(i + 1) * n + j] < threshold
552                && hbond[j * n + (i - 1)] < threshold;
553
554            if anti || anti_alt1 || anti_alt2 {
555                bridges.push(BetaBridge {
556                    i,
557                    j,
558                    is_parallel: false,
559                });
560                continue;
561            }
562
563            // Parallel bridge: NH(i)->CO(j-1) and NH(j)->CO(i)  (but different pattern)
564            // Pattern 1: NH(i-1)->CO(j) and NH(j)->CO(i)
565            let para1 = i > 0
566                && hbond[(i - 1) * n + j] < threshold
567                && hbond[j * n + i] < threshold;
568            // Pattern 2: NH(i)->CO(j) and NH(j+1)->CO(i)
569            let para2 = j + 1 < n
570                && hbond[i * n + j] < threshold
571                && hbond[(j + 1) * n + i] < threshold;
572
573            if para1 || para2 {
574                bridges.push(BetaBridge {
575                    i,
576                    j,
577                    is_parallel: true,
578                });
579            }
580        }
581    }
582
583    bridges
584}
585
586/// Assign E (extended strand) and B (isolated bridge) states.
587fn assign_bridges_and_strands(bridges: &[BetaBridge], _n: usize, states: &mut [DsspState]) {
588    // First pass: mark all bridge residues
589    for bridge in bridges {
590        // Only override coil
591        if states[bridge.i] == DsspState::C {
592            states[bridge.i] = DsspState::B;
593        }
594        if states[bridge.j] == DsspState::C {
595            states[bridge.j] = DsspState::B;
596        }
597    }
598
599    // Second pass: if two adjacent residues both have bridges, they form a strand (E)
600    // A strand = consecutive bridge residues
601    // Check for consecutive bridge residues
602    let bridge_residues: Vec<bool> = states.iter().map(|s| *s == DsspState::B).collect();
603    let n = bridge_residues.len();
604
605    for i in 0..n {
606        if !bridge_residues[i] {
607            continue;
608        }
609        // Check if this residue has an adjacent bridge residue
610        let has_neighbor = (i > 0 && bridge_residues[i - 1])
611            || (i + 1 < n && bridge_residues[i + 1]);
612        if has_neighbor {
613            states[i] = DsspState::E;
614        }
615    }
616
617    // Upgrade remaining B neighbors of E to E
618    let strand_residues: Vec<bool> = states.iter().map(|s| *s == DsspState::E).collect();
619    for i in 0..n {
620        if states[i] == DsspState::B {
621            let has_e_neighbor = (i > 0 && strand_residues[i - 1])
622                || (i + 1 < n && strand_residues[i + 1]);
623            if has_e_neighbor {
624                states[i] = DsspState::E;
625            }
626        }
627    }
628}
629
630/// Assign T (turn) to residues involved in H-bonded turns but not helices/strands.
631fn assign_turns(
632    turn3: &[bool],
633    turn4: &[bool],
634    turn5: &[bool],
635    n: usize,
636    states: &mut [DsspState],
637) {
638    for i in 0..n {
639        // Mark residues i+1 to i+turn_size-1 as Turn if there's a turn at i
640        for (turns, turn_size) in [(turn3, 3usize), (turn4, 4usize), (turn5, 5usize)] {
641            if i < turns.len() && turns[i] {
642                let start = i + 1;
643                let end = (i + turn_size).min(n);
644                for j in start..end {
645                    if states[j] == DsspState::C {
646                        states[j] = DsspState::T;
647                    }
648                }
649            }
650        }
651    }
652}
653
654/// Assign S (bend) where the angle between direction vectors
655/// CA(i-2)->CA(i) and CA(i)->CA(i+2) exceeds 70 degrees.
656///
657/// `angle_points(p1, p2, p3)` returns the vertex angle at p2, which is
658/// `180 - angle_between_direction_vectors`. So a bend exists when the
659/// vertex angle < 110 degrees.
660fn assign_bends(chain: &Chain, states: &mut [DsspState]) {
661    let n = chain.residues.len();
662    if n < 5 {
663        return;
664    }
665
666    for i in 2..n.saturating_sub(2) {
667        if states[i] != DsspState::C {
668            continue;
669        }
670
671        let ca_prev2 = chain.residues[i - 2].get_atom("CA");
672        let ca_curr = chain.residues[i].get_atom("CA");
673        let ca_next2 = chain.residues[i + 2].get_atom("CA");
674
675        if let (Some(p1), Some(p2), Some(p3)) = (ca_prev2, ca_curr, ca_next2) {
676            // Vertex angle at CA(i) = 180 - angle_between_direction_vectors
677            // DSSP bend: direction angle > 70° means vertex angle < 110°
678            let vertex_angle = angle_points(&p1.coords, &p2.coords, &p3.coords);
679            if vertex_angle < 110.0 {
680                states[i] = DsspState::S;
681            }
682        }
683    }
684}
685
686/// Compute backbone dihedral angles (phi, psi) for a residue given its neighbors.
687///
688/// - `prev`: previous residue (needed for phi: C(i-1)–N(i)–CA(i)–C(i))
689/// - `curr`: current residue
690/// - `next`: next residue (needed for psi: N(i)–CA(i)–C(i)–N(i+1))
691///
692/// Returns `Some((phi, psi))` in degrees if all required atoms are present.
693pub fn backbone_dihedrals(
694    prev: Option<&Residue>,
695    curr: &Residue,
696    next: Option<&Residue>,
697) -> Option<(f64, f64)> {
698    let n_i = curr.get_atom("N")?.coords;
699    let ca_i = curr.get_atom("CA")?.coords;
700    let c_i = curr.get_atom("C")?.coords;
701
702    let phi = if let Some(prev_res) = prev {
703        let c_prev = prev_res.get_atom("C")?.coords;
704        dihedral_points(&c_prev, &n_i, &ca_i, &c_i)
705    } else {
706        return None;
707    };
708
709    let psi = if let Some(next_res) = next {
710        let n_next = next_res.get_atom("N")?.coords;
711        dihedral_points(&n_i, &ca_i, &c_i, &n_next)
712    } else {
713        return None;
714    };
715
716    Some((phi, psi))
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::types::{Atom, Chain, Residue};
723
724    fn make_atom(name: &str, x: f64, y: f64, z: f64) -> Atom {
725        Atom {
726            serial: 1,
727            name: name.into(),
728            alt_loc: None,
729            coords: Point3D::new(x, y, z),
730            occupancy: 1.0,
731            temp_factor: 0.0,
732            element: None,
733            charge: None,
734            is_hetatm: false,
735        }
736    }
737
738    fn make_helix_chain() -> Chain {
739        // Build a synthetic alpha-helix with proper H-bond geometry.
740        // Alpha helix: 3.6 residues/turn, rise 1.5Å/residue, radius 2.3Å.
741        // Key: O(i) must be < 3.5Å from N(i+4).
742        //
743        // Strategy: first place all N atoms on the helix, then place O atoms
744        // offset toward N(i+4) so the H-bond distance criterion is met.
745        let n_residues = 12;
746        let rise = 1.5_f64; // Å per residue
747        let r = 2.3_f64; // helix radius
748        let angle_per_res = 100.0_f64.to_radians(); // ~100° per residue
749
750        // Compute N positions first
751        let n_positions: Vec<Point3D> = (0..n_residues)
752            .map(|i| {
753                let angle = i as f64 * angle_per_res;
754                Point3D::new(r * angle.cos(), r * angle.sin(), i as f64 * rise)
755            })
756            .collect();
757
758        let mut residues = Vec::new();
759        for i in 0..n_residues {
760            let n_pos = n_positions[i];
761            let angle = i as f64 * angle_per_res;
762            let z = i as f64 * rise;
763
764            let ca_pos = Point3D::new(
765                r * (angle + 0.3).cos(),
766                r * (angle + 0.3).sin(),
767                z + 0.4,
768            );
769            let c_pos = Point3D::new(
770                r * (angle + 0.6).cos(),
771                r * (angle + 0.6).sin(),
772                z + 0.8,
773            );
774
775            // Place O near N(i+4) so that dist(O_i, N_{i+4}) < 3.5Å.
776            // We place O 2.5Å from C along the C→N(i+4) direction, giving
777            // an O→N(i+4) distance of ~2.7Å (well within the 3.5Å cutoff).
778            let o_pos = if i + 4 < n_residues {
779                let target = n_positions[i + 4];
780                let dir = target.sub(&c_pos);
781                let d = dir.norm();
782                if d > 1e-10 {
783                    c_pos.add(&dir.scale(2.5 / d))
784                } else {
785                    Point3D::new(c_pos.x, c_pos.y + 1.24, c_pos.z)
786                }
787            } else {
788                Point3D::new(c_pos.x, c_pos.y + 1.24, c_pos.z)
789            };
790
791            residues.push(Residue {
792                name: "ALA".into(),
793                seq_num: i as i32 + 1,
794                i_code: None,
795                atoms: vec![
796                    make_atom("N", n_pos.x, n_pos.y, n_pos.z),
797                    make_atom("CA", ca_pos.x, ca_pos.y, ca_pos.z),
798                    make_atom("C", c_pos.x, c_pos.y, c_pos.z),
799                    make_atom("O", o_pos.x, o_pos.y, o_pos.z),
800                ],
801            });
802        }
803        Chain::new('A', residues)
804    }
805
806    #[test]
807    fn short_chain_all_coil() {
808        let chain = Chain::new(
809            'A',
810            vec![
811                Residue {
812                    name: "ALA".into(),
813                    seq_num: 1,
814                    i_code: None,
815                    atoms: vec![
816                        make_atom("N", 0.0, 0.0, 0.0),
817                        make_atom("CA", 1.5, 0.0, 0.0),
818                        make_atom("C", 3.0, 0.0, 0.0),
819                        make_atom("O", 3.5, 1.0, 0.0),
820                    ],
821                },
822                Residue {
823                    name: "GLY".into(),
824                    seq_num: 2,
825                    i_code: None,
826                    atoms: vec![
827                        make_atom("N", 4.0, 0.0, 0.0),
828                        make_atom("CA", 5.5, 0.0, 0.0),
829                        make_atom("C", 7.0, 0.0, 0.0),
830                        make_atom("O", 7.5, 1.0, 0.0),
831                    ],
832                },
833            ],
834        );
835        let ss = assign_secondary_structure(&chain).unwrap();
836        assert_eq!(ss.assignments.len(), 2);
837        // Too short for helix/sheet, should be coil or turn
838        for a in &ss.assignments {
839            assert!(
840                *a == SecondaryStructure::Coil || *a == SecondaryStructure::Turn,
841                "short chain should be coil/turn"
842            );
843        }
844    }
845
846    #[test]
847    fn helix_detection() {
848        let chain = make_helix_chain();
849        let ss = assign_secondary_structure(&chain).unwrap();
850        // At least some residues should be helix
851        let (h, _, _, _) = ss.counts();
852        assert!(
853            h > 0,
854            "helix chain should have helical residues, got: {:?}",
855            ss.assignments
856        );
857    }
858
859    #[test]
860    fn sheet_detection() {
861        // Build two strands running antiparallel with close O-N contacts.
862        let mut residues = Vec::new();
863        // Strand 1: residues 1-5, running along +x
864        for i in 0..5 {
865            let x = i as f64 * 3.5;
866            residues.push(Residue {
867                name: "ALA".into(),
868                seq_num: i + 1,
869                i_code: None,
870                atoms: vec![
871                    make_atom("N", x, 0.0, 0.0),
872                    make_atom("CA", x + 1.0, 0.0, 0.0),
873                    make_atom("C", x + 2.0, 0.0, 0.0),
874                    make_atom("O", x + 2.0, 1.0, 0.0),
875                ],
876            });
877        }
878        // Spacer residues 6-10 (far away)
879        for i in 5..10 {
880            residues.push(Residue {
881                name: "ALA".into(),
882                seq_num: i + 1,
883                i_code: None,
884                atoms: vec![
885                    make_atom("N", 50.0, 50.0, i as f64 * 10.0),
886                    make_atom("CA", 51.0, 50.0, i as f64 * 10.0),
887                    make_atom("C", 52.0, 50.0, i as f64 * 10.0),
888                    make_atom("O", 52.0, 51.0, i as f64 * 10.0),
889                ],
890            });
891        }
892        // Strand 2: residues 11-15, antiparallel (running along -x)
893        // O of strand1[i] close to N of strand2[4-i] and vice versa
894        for i in 0..5 {
895            let x = (4 - i) as f64 * 3.5;
896            residues.push(Residue {
897                name: "ALA".into(),
898                seq_num: (i + 11) as i32,
899                i_code: None,
900                atoms: vec![
901                    make_atom("N", x + 2.0, 2.5, 0.0), // Close to O of strand 1
902                    make_atom("CA", x + 1.0, 2.5, 0.0),
903                    make_atom("C", x, 2.5, 0.0),
904                    make_atom("O", x, 1.5, 0.0), // Close to N of strand 1
905                ],
906            });
907        }
908
909        let chain = Chain::new('A', residues);
910        let ss = assign_secondary_structure(&chain).unwrap();
911        let (_, e, _, _) = ss.counts();
912        assert!(e > 0, "should detect sheet residues, got: {:?}", ss.assignments);
913    }
914
915    #[test]
916    fn backbone_dihedral_computation() {
917        let prev = Residue {
918            name: "ALA".into(),
919            seq_num: 1,
920            i_code: None,
921            atoms: vec![
922                make_atom("N", -1.0, 0.0, 0.0),
923                make_atom("CA", 0.0, 0.0, 0.0),
924                make_atom("C", 1.0, 0.0, 0.0),
925                make_atom("O", 1.0, 1.0, 0.0),
926            ],
927        };
928        let curr = Residue {
929            name: "GLY".into(),
930            seq_num: 2,
931            i_code: None,
932            atoms: vec![
933                make_atom("N", 2.0, 0.0, 0.0),
934                make_atom("CA", 3.0, 0.0, 0.0),
935                make_atom("C", 4.0, 0.0, 0.0),
936                make_atom("O", 4.0, 1.0, 0.0),
937            ],
938        };
939        let next = Residue {
940            name: "ALA".into(),
941            seq_num: 3,
942            i_code: None,
943            atoms: vec![
944                make_atom("N", 5.0, 0.0, 0.0),
945                make_atom("CA", 6.0, 0.0, 0.0),
946                make_atom("C", 7.0, 0.0, 0.0),
947                make_atom("O", 7.0, 1.0, 0.0),
948            ],
949        };
950        let result = backbone_dihedrals(Some(&prev), &curr, Some(&next));
951        assert!(result.is_some(), "should compute phi/psi");
952    }
953
954    // ---- Full DSSP tests ----
955
956    #[test]
957    fn dssp_empty_chain_error() {
958        let chain = Chain::new('A', vec![]);
959        assert!(dssp(&chain).is_err());
960    }
961
962    #[test]
963    fn dssp_short_chain_coil() {
964        let chain = Chain::new(
965            'A',
966            vec![
967                Residue {
968                    name: "ALA".into(),
969                    seq_num: 1,
970                    i_code: None,
971                    atoms: vec![
972                        make_atom("N", 0.0, 0.0, 0.0),
973                        make_atom("CA", 1.5, 0.0, 0.0),
974                        make_atom("C", 3.0, 0.0, 0.0),
975                        make_atom("O", 3.5, 1.0, 0.0),
976                    ],
977                },
978                Residue {
979                    name: "GLY".into(),
980                    seq_num: 2,
981                    i_code: None,
982                    atoms: vec![
983                        make_atom("N", 4.0, 0.0, 0.0),
984                        make_atom("CA", 5.5, 0.0, 0.0),
985                        make_atom("C", 7.0, 0.0, 0.0),
986                        make_atom("O", 7.5, 1.0, 0.0),
987                    ],
988                },
989            ],
990        );
991        let result = dssp(&chain).unwrap();
992        assert_eq!(result.states.len(), 2);
993        // Short chain without H-bonds should not be helix or strand
994        for s in &result.states {
995            assert!(
996                *s != DsspState::H && *s != DsspState::E,
997                "short chain should not be helix/strand"
998            );
999        }
1000    }
1001
1002    #[test]
1003    fn dssp_helix_chain() {
1004        let chain = make_helix_chain();
1005        let result = dssp(&chain).unwrap();
1006        assert_eq!(result.states.len(), 12);
1007        // The DSSP should detect some helical or turn structure
1008        // (energy-based DSSP on synthetic helix may classify as H, G, or T)
1009        let has_structure = result.states.iter().any(|s| *s != DsspState::C);
1010        assert!(
1011            has_structure,
1012            "helix chain should have some structure assigned, got: {}",
1013            result.to_string_codes()
1014        );
1015    }
1016
1017    #[test]
1018    fn dssp_state_codes() {
1019        assert_eq!(DsspState::H.code(), 'H');
1020        assert_eq!(DsspState::G.code(), 'G');
1021        assert_eq!(DsspState::I.code(), 'I');
1022        assert_eq!(DsspState::E.code(), 'E');
1023        assert_eq!(DsspState::B.code(), 'B');
1024        assert_eq!(DsspState::T.code(), 'T');
1025        assert_eq!(DsspState::S.code(), 'S');
1026        assert_eq!(DsspState::C.code(), 'C');
1027    }
1028
1029    #[test]
1030    fn dssp_to_simplified_mapping() {
1031        assert_eq!(DsspState::H.to_simplified(), SecondaryStructure::Helix);
1032        assert_eq!(DsspState::G.to_simplified(), SecondaryStructure::Helix);
1033        assert_eq!(DsspState::I.to_simplified(), SecondaryStructure::Helix);
1034        assert_eq!(DsspState::E.to_simplified(), SecondaryStructure::Sheet);
1035        assert_eq!(DsspState::B.to_simplified(), SecondaryStructure::Sheet);
1036        assert_eq!(DsspState::T.to_simplified(), SecondaryStructure::Turn);
1037        assert_eq!(DsspState::S.to_simplified(), SecondaryStructure::Turn);
1038        assert_eq!(DsspState::C.to_simplified(), SecondaryStructure::Coil);
1039    }
1040
1041    #[test]
1042    fn dssp_assignment_to_simplified() {
1043        let chain = make_helix_chain();
1044        let dssp_result = dssp(&chain).unwrap();
1045        let simplified = dssp_result.to_simplified();
1046        assert_eq!(simplified.chain_id, 'A');
1047        assert_eq!(simplified.assignments.len(), 12);
1048    }
1049
1050    #[test]
1051    fn dssp_string_codes() {
1052        let assignment = DsspAssignment {
1053            chain_id: 'A',
1054            states: vec![DsspState::H, DsspState::H, DsspState::C, DsspState::E],
1055        };
1056        assert_eq!(assignment.to_string_codes(), "HHCE");
1057    }
1058
1059    #[test]
1060    fn dssp_counts() {
1061        let assignment = DsspAssignment {
1062            chain_id: 'A',
1063            states: vec![
1064                DsspState::H,
1065                DsspState::H,
1066                DsspState::G,
1067                DsspState::E,
1068                DsspState::B,
1069                DsspState::T,
1070                DsspState::S,
1071                DsspState::C,
1072            ],
1073        };
1074        let counts = assignment.counts();
1075        assert_eq!(counts.h, 2);
1076        assert_eq!(counts.g, 1);
1077        assert_eq!(counts.i, 0);
1078        assert_eq!(counts.e, 1);
1079        assert_eq!(counts.b, 1);
1080        assert_eq!(counts.t, 1);
1081        assert_eq!(counts.s, 1);
1082        assert_eq!(counts.c, 1);
1083    }
1084
1085    #[test]
1086    fn dssp_bend_detection() {
1087        // Build a chain with a sharp bend at residue 3 (CA angle > 70 degrees)
1088        // Place CAs: 0=(0,0,0), 1=(3.8,0,0), 2=(7.6,0,0), 3=(7.6,3.8,0), 4=(7.6,7.6,0)
1089        // Angle at CA(2): CA(0)->CA(2)->CA(4)
1090        // Vector CA(0)->CA(2) = (7.6,0,0), Vector CA(4)->CA(2) = (0,-7.6,0) => 90 degrees > 70
1091        let mut residues = Vec::new();
1092        let positions = vec![
1093            (0.0, 0.0, 0.0),
1094            (3.8, 0.0, 0.0),
1095            (7.6, 0.0, 0.0),
1096            (7.6, 3.8, 0.0),
1097            (7.6, 7.6, 0.0),
1098        ];
1099        for (i, (x, y, z)) in positions.iter().enumerate() {
1100            residues.push(Residue {
1101                name: "ALA".into(),
1102                seq_num: i as i32 + 1,
1103                i_code: None,
1104                atoms: vec![
1105                    make_atom("N", x - 0.5, *y, *z),
1106                    make_atom("CA", *x, *y, *z),
1107                    make_atom("C", x + 0.5, *y, *z),
1108                    make_atom("O", x + 0.5, y + 1.0, *z),
1109                ],
1110            });
1111        }
1112        let chain = Chain::new('A', residues);
1113        let result = dssp(&chain).unwrap();
1114        // Residue at index 2 should be detected as a bend (S)
1115        assert_eq!(
1116            result.states[2],
1117            DsspState::S,
1118            "residue at bend should be S, got: {}",
1119            result.to_string_codes()
1120        );
1121    }
1122}