Skip to content

Commit cfe1aae

Browse files
zachcpclaude
andcommitted
feat(ferritin-core): consolidate chemistry constants + dead-code cleanup (ferritin-4tl)
Move atom37-slot and amino-acid-index constants from ferritin-plms into ferritin-core::info so any downstream crate can consume them without taking a candle dependency: info/amino_acids — aa3to1 / aa1to_int / int_to_aa1 / ALPHABET info/atom37 — ATOM37_NAMES, atom37_index, atom_order, AAAtom enum, vdw_radius, NUM_ATOM37 / NUM_RESIDUES ferritin-plms re-exports from ferritin-core; no callers change. strum added to ferritin-core for AAAtom derives. Also remove dead code that accumulated in the IO layer: - cif.rs: get_value / _parse_value chain deleted; get_model_count deleted; CIFDataBlock._name renamed; FromStr import removed - pdb.rs: get_atom_indices / get_model_length deleted; truncate_id deleted; Ordering import removed; unused pub methods annotated allow(dead_code) - info/constants.rs: default_distance_range annotated allow(dead_code) ferritin-core now builds with zero warnings; 99 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c17a57a commit cfe1aae

10 files changed

Lines changed: 259 additions & 307 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ferritin-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ intern = []
1313
[dependencies]
1414
anyhow.workspace = true
1515
itertools.workspace = true
16+
strum.workspace = true
1617

1718
[dev-dependencies]
1819
ferritin-test-data = { path = "../ferritin-test-data" }
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//! Amino acid conversion utilities.
2+
//!
3+
//! Single-letter ↔ three-letter ↔ integer mappings for the 20 standard amino
4+
//! acids plus the catch-all `UNK` / `'X'` / index 20.
5+
6+
/// One-letter code alphabet for the 20 standard amino acids plus UNK.
7+
pub const ALPHABET: [char; 21] = [
8+
'A', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'K', 'L',
9+
'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'Y', 'X',
10+
];
11+
12+
/// Convert a 3-letter amino acid code to a 1-letter code.
13+
#[rustfmt::skip]
14+
pub fn aa3to1(aa: &str) -> char {
15+
match aa {
16+
"ALA" => 'A', "CYS" => 'C', "ASP" => 'D',
17+
"GLU" => 'E', "PHE" => 'F', "GLY" => 'G',
18+
"HIS" => 'H', "ILE" => 'I', "LYS" => 'K',
19+
"LEU" => 'L', "MET" => 'M', "ASN" => 'N',
20+
"PRO" => 'P', "GLN" => 'Q', "ARG" => 'R',
21+
"SER" => 'S', "THR" => 'T', "VAL" => 'V',
22+
"TRP" => 'W', "TYR" => 'Y', _ => 'X',
23+
}
24+
}
25+
26+
/// Convert a 1-letter amino acid code to an integer index (0–20, UNK→20).
27+
#[rustfmt::skip]
28+
pub fn aa1to_int(aa: char) -> u32 {
29+
match aa {
30+
'A' => 0, 'C' => 1, 'D' => 2,
31+
'E' => 3, 'F' => 4, 'G' => 5,
32+
'H' => 6, 'I' => 7, 'K' => 8,
33+
'L' => 9, 'M' => 10, 'N' => 11,
34+
'P' => 12, 'Q' => 13, 'R' => 14,
35+
'S' => 15, 'T' => 16, 'V' => 17,
36+
'W' => 18, 'Y' => 19, _ => 20,
37+
}
38+
}
39+
40+
/// Convert an integer index (0–20) to a 1-letter amino acid code.
41+
#[rustfmt::skip]
42+
pub fn int_to_aa1(aa_int: u32) -> char {
43+
match aa_int {
44+
0 => 'A', 1 => 'C', 2 => 'D',
45+
3 => 'E', 4 => 'F', 5 => 'G',
46+
6 => 'H', 7 => 'I', 8 => 'K',
47+
9 => 'L', 10 => 'M', 11 => 'N',
48+
12 => 'P', 13 => 'Q', 14 => 'R',
49+
15 => 'S', 16 => 'T', 17 => 'V',
50+
18 => 'W', 19 => 'Y', 20 => 'X',
51+
_ => 'X',
52+
}
53+
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use super::*;
58+
59+
#[test]
60+
fn test_aa3to1_roundtrip() {
61+
assert_eq!(aa3to1("ALA"), 'A');
62+
assert_eq!(aa3to1("TRP"), 'W');
63+
assert_eq!(aa3to1("UNK"), 'X');
64+
assert_eq!(aa3to1("???"), 'X');
65+
}
66+
67+
#[test]
68+
fn test_aa1to_int_roundtrip() {
69+
for (i, &ch) in ALPHABET.iter().enumerate() {
70+
assert_eq!(aa1to_int(ch) as usize, i, "ALPHABET[{i}] = {ch}");
71+
}
72+
assert_eq!(aa1to_int('?'), 20);
73+
}
74+
75+
#[test]
76+
fn test_int_to_aa1_roundtrip() {
77+
for i in 0u32..=20 {
78+
let ch = int_to_aa1(i);
79+
assert_eq!(aa1to_int(ch), i, "round-trip failed at index {i}");
80+
}
81+
assert_eq!(int_to_aa1(99), 'X');
82+
}
83+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//! Canonical 37-slot heavy-atom representation and atom-name lookups.
2+
//!
3+
//! The atom37 scheme is the standard representation used by ESMFold, AlphaFold2,
4+
//! and related models: every amino acid is represented with up to 37 possible
5+
//! heavy-atom positions, each at a fixed slot index defined by [`ATOM37_NAMES`].
6+
//!
7+
//! # Key items
8+
//! - [`NUM_ATOM37`] / [`NUM_RESIDUES`] — dimension constants
9+
//! - [`ATOM37_NAMES`] — canonical name→slot mapping (array)
10+
//! - [`atom_order`] — `HashMap` version of the same mapping
11+
//! - [`atom37_index`] — slot lookup by atom name
12+
//! - [`AAAtom`] — typed enum mirroring the slot indices
13+
//! - [`vdw_radius`] — van der Waals radii for common protein elements
14+
15+
use std::collections::HashMap;
16+
use strum::{Display, EnumIter, EnumString};
17+
18+
// ── Dimension constants ───────────────────────────────────────────────────────
19+
20+
pub const NUM_ATOM37: usize = 37;
21+
pub const NUM_RESIDUES: usize = 21; // 20 standard AAs + UNK
22+
23+
// ── Atom37 slot names ─────────────────────────────────────────────────────────
24+
25+
/// Canonical atom37 names in slot order.
26+
///
27+
/// The index of each name is its atom37 slot number, matching [`AAAtom`].
28+
#[rustfmt::skip]
29+
pub const ATOM37_NAMES: [&str; NUM_ATOM37] = [
30+
"N", "CA", "C", "CB", "O",
31+
"CG", "CG1", "CG2", "OG", "OG1",
32+
"SG", "CD", "CD1", "CD2", "ND1",
33+
"ND2", "OD1", "OD2", "SD", "CE",
34+
"CE1", "CE2", "CE3", "NE", "NE1",
35+
"NE2", "OE1", "OE2", "CH2", "NH1",
36+
"NH2", "OH", "CZ", "CZ2", "CZ3",
37+
"NZ", "OXT",
38+
];
39+
40+
/// Map from atom name (e.g. `"CA"`) to its atom37 slot index.
41+
pub fn atom_order() -> HashMap<String, usize> {
42+
ATOM37_NAMES
43+
.iter()
44+
.enumerate()
45+
.map(|(i, &name)| (name.to_string(), i))
46+
.collect()
47+
}
48+
49+
/// Returns the atom37 slot index for a named atom, or `None` if unknown.
50+
pub fn atom37_index(name: &str) -> Option<usize> {
51+
ATOM37_NAMES.iter().position(|&n| n == name)
52+
}
53+
54+
// ── AAAtom enum ───────────────────────────────────────────────────────────────
55+
56+
/// Typed atom37 slots; discriminants match [`ATOM37_NAMES`] indices.
57+
///
58+
/// `Unknown = -1` is used as a sentinel for "atom not present in this residue"
59+
/// in atom14 tables. Callers working with `usize` slot indices should use
60+
/// [`atom37_index`] instead.
61+
#[rustfmt::skip]
62+
#[derive(Debug, Clone, Copy, PartialEq, Display, EnumString, EnumIter)]
63+
pub enum AAAtom {
64+
N = 0, CA = 1, C = 2, CB = 3, O = 4,
65+
CG = 5, CG1 = 6, CG2 = 7, OG = 8, OG1 = 9,
66+
SG = 10, CD = 11, CD1 = 12, CD2 = 13, ND1 = 14,
67+
ND2 = 15, OD1 = 16, OD2 = 17, SD = 18, CE = 19,
68+
CE1 = 20, CE2 = 21, CE3 = 22, NE = 23, NE1 = 24,
69+
NE2 = 25, OE1 = 26, OE2 = 27, CH2 = 28, NH1 = 29,
70+
NH2 = 30, OH = 31, CZ = 32, CZ2 = 33, CZ3 = 34,
71+
NZ = 35, OXT = 36,
72+
Unknown = -1,
73+
}
74+
75+
impl AAAtom {
76+
pub fn to_index(&self) -> usize {
77+
*self as usize
78+
}
79+
}
80+
81+
// ── Van der Waals radii ───────────────────────────────────────────────────────
82+
83+
/// Van der Waals radius (Å) for elements commonly found in proteins.
84+
///
85+
/// Source: Bondi (1964) / standard crystallographic values.
86+
pub fn vdw_radius(element: &str) -> f32 {
87+
match element {
88+
"H" => 1.20,
89+
"C" => 1.70,
90+
"N" => 1.55,
91+
"O" => 1.52,
92+
"S" => 1.80,
93+
"P" => 1.80,
94+
"F" => 1.47,
95+
"CL" | "Cl" => 1.75,
96+
"BR" | "Br" => 1.85,
97+
"I" => 1.98,
98+
"SE" | "Se" => 1.90,
99+
_ => 1.70,
100+
}
101+
}
102+
103+
// ── Tests ─────────────────────────────────────────────────────────────────────
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
109+
#[test]
110+
fn test_atom_order_completeness() {
111+
let order = atom_order();
112+
assert_eq!(order.len(), NUM_ATOM37);
113+
assert_eq!(order["N"], 0);
114+
assert_eq!(order["CA"], 1);
115+
assert_eq!(order["C"], 2);
116+
assert_eq!(order["CB"], 3);
117+
assert_eq!(order["O"], 4);
118+
}
119+
120+
#[test]
121+
fn test_atom37_index_roundtrip() {
122+
for (i, &name) in ATOM37_NAMES.iter().enumerate() {
123+
assert_eq!(atom37_index(name), Some(i));
124+
}
125+
assert_eq!(atom37_index("ZZZ"), None);
126+
}
127+
128+
#[test]
129+
fn test_aaatom_discriminants_match_names() {
130+
assert_eq!(AAAtom::N as i32, 0);
131+
assert_eq!(AAAtom::CA as i32, 1);
132+
assert_eq!(AAAtom::OXT as i32, 36);
133+
assert_eq!(AAAtom::Unknown as i32, -1);
134+
}
135+
136+
#[test]
137+
fn test_vdw_radius_known_elements() {
138+
assert!((vdw_radius("C") - 1.70).abs() < 1e-6);
139+
assert!((vdw_radius("N") - 1.55).abs() < 1e-6);
140+
assert!((vdw_radius("O") - 1.52).abs() < 1e-6);
141+
assert!((vdw_radius("S") - 1.80).abs() < 1e-6);
142+
}
143+
144+
#[test]
145+
fn test_vdw_radius_unknown_defaults_to_carbon() {
146+
assert!((vdw_radius("X") - 1.70).abs() < 1e-6);
147+
}
148+
}

crates/ferritin-core/src/info/constants.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use std::collections::{HashMap, HashSet};
2121
use std::sync::OnceLock;
2222

2323
#[rustfmt::skip]
24+
#[allow(dead_code)]
2425
pub(crate) fn default_distance_range(a: &str, b: &str) -> (f32, f32) {
2526
match (a, b) {
2627
// https://github.com/biotite-dev/biotite/blob/main/src/biotite/structure/bonds.pyx#L1341C1-L1389C1
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
pub mod amino_acids;
2+
pub mod atom37;
13
pub mod constants;
24
pub mod elements;

crates/ferritin-core/src/io/cif.rs

Lines changed: 3 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ use std::collections::HashMap;
1313
use std::error::Error;
1414
use std::fmt;
1515
use std::fs;
16-
use std::str::FromStr;
1716
use std::sync::Arc;
1817

1918
/// Custom error types for CIF parsing operations
2019
#[derive(Debug)]
2120
pub enum CIFError {
2221
InvalidFile(String),
2322
IOError(std::io::Error),
23+
#[allow(dead_code)]
2424
ValueError(String),
2525
OSError(String),
2626
}
@@ -47,7 +47,7 @@ impl From<std::io::Error> for CIFError {
4747
/// Represents a data block in a CIF file
4848
#[derive(Debug)]
4949
struct CIFDataBlock {
50-
name: String,
50+
_name: String,
5151
categories: HashMap<String, CIFCategory>,
5252
}
5353

@@ -73,20 +73,6 @@ impl CIFCategory {
7373
fn get_column_index(&self, column_name: &str) -> Option<usize> {
7474
self.columns.iter().position(|c| c == column_name)
7575
}
76-
77-
/// Get value for a specific row and column
78-
fn get_value(&self, row: usize, column: &str) -> Option<&str> {
79-
if row >= self.data.len() {
80-
return None;
81-
}
82-
83-
let col_idx = self.get_column_index(column)?;
84-
if col_idx >= self.data[row].len() {
85-
return None;
86-
}
87-
88-
Some(&self.data[row][col_idx])
89-
}
9076
}
9177

9278
/// This is a low-level abstraction of a CIF file.
@@ -150,7 +136,7 @@ impl CIFFile {
150136
}
151137
// Create new data block
152138
current_data_block = Some(CIFDataBlock {
153-
name: line[5..].to_string(),
139+
_name: line[5..].to_string(),
154140
categories: HashMap::new(),
155141
});
156142
in_loop = false;
@@ -303,58 +289,6 @@ impl CIFFile {
303289
Ok(())
304290
}
305291

306-
/// Helper function to parse a numeric value from a category row
307-
fn _parse_value<T: FromStr>(
308-
&self,
309-
category: &CIFCategory,
310-
row: usize,
311-
column: &str,
312-
) -> Result<T, CIFError> {
313-
let value_str = category.get_value(row, column).ok_or_else(|| {
314-
CIFError::InvalidFile(format!("Missing value for {}.{}", category.name, column))
315-
})?;
316-
317-
value_str.parse().map_err(|_| {
318-
CIFError::InvalidFile(format!(
319-
"Failed to parse '{}' as a number for {}.{}",
320-
value_str, category.name, column
321-
))
322-
})
323-
}
324-
325-
/// Get the number of models in the CIF file by inspecting pdbx_PDB_model_num values.
326-
pub fn get_model_count(&self) -> Result<usize, CIFError> {
327-
if self.data_blocks.is_empty() {
328-
return Ok(0);
329-
}
330-
331-
let block = &self.data_blocks[self.current_block];
332-
let atom_category = match block.categories.get("atom_site") {
333-
Some(cat) => cat,
334-
None => return Ok(0),
335-
};
336-
337-
let model_num_col = atom_category.get_column_index("pdbx_PDB_model_num");
338-
if model_num_col.is_none() {
339-
return Ok(1);
340-
}
341-
342-
let col_idx = model_num_col.unwrap();
343-
let mut max_model: i32 = 1;
344-
345-
for row in &atom_category.data {
346-
if col_idx < row.len() {
347-
if let Ok(model_num) = row[col_idx].parse::<i32>() {
348-
if model_num > max_model {
349-
max_model = model_num;
350-
}
351-
}
352-
}
353-
}
354-
355-
Ok(max_model as usize)
356-
}
357-
358292
/// Parse CIF file into an AtomCollection (legacy API - returns flattened structure)
359293
pub fn parse_to_atom_collection(&self) -> Result<AtomCollection, CIFError> {
360294
if self.data_blocks.is_empty() {

0 commit comments

Comments
 (0)