Skip to main content

ferritin_plms/esm3/utils/
affine3d.rs

1//! Affine3D local reference frames for ESM3 geometric attention.
2//!
3//! Each residue gets a local coordinate frame defined by its backbone (N, CA, C) atoms.
4//! The rotation matrix columns are orthonormal frame axes; the translation is the CA position.
5
6use candle_core::{D, Result, Tensor};
7
8/// Per-residue local reference frame: rotation matrix + translation.
9///
10/// `rot`:   `(*, 3, 3)` — orthonormal rotation matrix, columns are the frame axes.
11/// `trans`: `(*,    3)` — CA position in global coordinates.
12pub struct Affine3D {
13    pub rot: Tensor,
14    pub trans: Tensor,
15}
16
17impl Affine3D {
18    pub fn new(rot: Tensor, trans: Tensor) -> Self {
19        Self { rot, trans }
20    }
21
22    /// Rotate vectors from local frame to global frame.
23    ///
24    /// `rot`: `(*, 3, 3)`, `v`: `(*, H, 3)` → `(*, H, 3)`.
25    ///
26    /// In row-vector convention: `v @ rot^T`.
27    pub fn apply_rot(rot: &Tensor, v: &Tensor) -> Result<Tensor> {
28        let rot_t = rot.transpose(D::Minus2, D::Minus1)?;
29        v.matmul(&rot_t)
30    }
31
32    /// Rotate vectors from global frame to local frame (inverse rotation).
33    ///
34    /// `rot`: `(*, 3, 3)`, `v`: `(*, H, 3)` → `(*, H, 3)`.
35    ///
36    /// For orthogonal matrices: inverse = transpose, so `v @ rot`.
37    pub fn apply_rot_inv(rot: &Tensor, v: &Tensor) -> Result<Tensor> {
38        v.matmul(rot)
39    }
40
41    /// Apply full affine transform (rotate + translate) to `v` of shape `(*, H, 3)`.
42    ///
43    /// Returns `(*, H, 3)` in global coordinates.
44    pub fn apply(&self, v: &Tensor) -> Result<Tensor> {
45        let rotated = Self::apply_rot(&self.rot, v)?; // (*, H, 3)
46        let trans = self.trans.unsqueeze(D::Minus2)?; // (*, 1, 3)
47        rotated.broadcast_add(&trans)
48    }
49
50    /// Build per-residue local frames from backbone atom coordinates.
51    ///
52    /// `coords`: `(B, L, 3, 3)` — atom order is `(N, CA, C)`.
53    ///
54    /// Frame construction follows AlphaFold2/ESM convention (Graham-Schmidt on CA-C and CA-N):
55    /// - x-axis: normalized `CA - C`
56    /// - y-axis: normalized component of `N - CA` perpendicular to x-axis
57    /// - z-axis: `x × y`
58    /// - origin: CA position
59    ///
60    /// Returns `(Affine3D, mask)` where `mask (B, L)` is `true` (u8=1) where both
61    /// backbone vectors have non-trivial length (atoms are present).
62    pub fn build_affine3d_from_coordinates(coords: &Tensor) -> Result<(Self, Tensor)> {
63        // Extract atom positions: each (B, L, 3)
64        let n_pos = coords.narrow(D::Minus2, 0, 1)?.squeeze(D::Minus2)?;
65        let ca_pos = coords.narrow(D::Minus2, 1, 1)?.squeeze(D::Minus2)?;
66        let c_pos = coords.narrow(D::Minus2, 2, 1)?.squeeze(D::Minus2)?;
67
68        // Frame edge vectors from CA
69        let x_axis = ca_pos.sub(&c_pos)?; // CA - C
70        let xy_plane = n_pos.sub(&ca_pos)?; // N - CA
71
72        // Graham-Schmidt orthogonalization
73        let rot = graham_schmidt(&x_axis, &xy_plane, 1e-10)?; // (B, L, 3, 3)
74
75        // Validity mask: both backbone vectors have non-trivial length
76        let eps = 1e-8f64;
77        let v1_norm_sq = x_axis.sqr()?.sum(D::Minus1)?; // (B, L)
78        let v2_norm_sq = xy_plane.sqr()?.sum(D::Minus1)?;
79        let mask = (v1_norm_sq.gt(eps)? * v2_norm_sq.gt(eps)?)?;
80
81        Ok((Self::new(rot, ca_pos), mask))
82    }
83}
84
85/// Graham-Schmidt orthogonalization to produce a rotation matrix.
86///
87/// `x_axis`, `xy_plane`: `(*, 3)` vectors defining the frame.
88/// Returns `(*, 3, 3)` where columns are the orthonormal basis `[e_x, e_1, e_2]`.
89fn graham_schmidt(x_axis: &Tensor, xy_plane: &Tensor, eps: f64) -> Result<Tensor> {
90    // Normalize x_axis → e_x; affine(1.0, eps) = tensor * 1 + eps = tensor + eps
91    let norm_x = x_axis
92        .sqr()?
93        .sum_keepdim(D::Minus1)?
94        .sqrt()?
95        .affine(1.0, eps)?;
96    let e_x = x_axis.broadcast_div(&norm_x)?;
97
98    // e_1 = xy_plane - proj(xy_plane, e_x), then normalize
99    let dot = e_x.mul(xy_plane)?.sum_keepdim(D::Minus1)?; // (*, 1)
100    let e_1 = xy_plane.sub(&e_x.broadcast_mul(&dot)?)?;
101    let norm_1 = e_1
102        .sqr()?
103        .sum_keepdim(D::Minus1)?
104        .sqrt()?
105        .affine(1.0, eps)?;
106    let e_1 = e_1.broadcast_div(&norm_1)?;
107
108    // e_2 = e_x × e_1
109    let e_2 = cross_product(&e_x, &e_1)?;
110
111    // Cat as columns → (*, 3, 3): each basis vector is (*, 3, 1) after unsqueeze
112    Tensor::cat(
113        &[
114            &e_x.unsqueeze(D::Minus1)?,
115            &e_1.unsqueeze(D::Minus1)?,
116            &e_2.unsqueeze(D::Minus1)?,
117        ],
118        D::Minus1,
119    )
120}
121
122/// Cross product of two `(*, 3)` tensors.
123fn cross_product(a: &Tensor, b: &Tensor) -> Result<Tensor> {
124    let a0 = a.narrow(D::Minus1, 0, 1)?;
125    let a1 = a.narrow(D::Minus1, 1, 1)?;
126    let a2 = a.narrow(D::Minus1, 2, 1)?;
127    let b0 = b.narrow(D::Minus1, 0, 1)?;
128    let b1 = b.narrow(D::Minus1, 1, 1)?;
129    let b2 = b.narrow(D::Minus1, 2, 1)?;
130    let c0 = a1.mul(&b2)?.sub(&a2.mul(&b1)?)?;
131    let c1 = a2.mul(&b0)?.sub(&a0.mul(&b2)?)?;
132    let c2 = a0.mul(&b1)?.sub(&a1.mul(&b0)?)?;
133    Tensor::cat(&[&c0, &c1, &c2], D::Minus1)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use candle_core::{Device, DType, Tensor};
140
141    #[test]
142    fn test_apply_rot_identity() -> Result<()> {
143        let device = &Device::Cpu;
144        // Identity rotation: v should be unchanged
145        let rot = Tensor::eye(3, candle_core::DType::F32, device)?
146            .unsqueeze(0)?
147            .unsqueeze(0)?; // (1, 1, 3, 3)
148        let v = Tensor::randn(0f32, 1f32, (1, 1, 4, 3), device)?;
149        let out = Affine3D::apply_rot(&rot, &v)?;
150        let diff = out.sub(&v)?.sqr()?.sum_all()?.to_scalar::<f32>()?;
151        assert!(diff < 1e-5, "identity rotation changed vectors");
152        Ok(())
153    }
154
155    #[test]
156    fn test_apply_rot_roundtrip() -> Result<()> {
157        let device = &Device::Cpu;
158        // Random orthogonal matrix via Gram-Schmidt
159        let v1 = Tensor::randn(0f32, 1f32, (1, 1, 3), device)?;
160        let v2 = Tensor::randn(0f32, 1f32, (1, 1, 3), device)?;
161        let rot = graham_schmidt(&v1, &v2, 1e-10)?; // (1, 1, 3, 3)
162
163        let x = Tensor::randn(0f32, 1f32, (1, 1, 5, 3), device)?;
164        // apply then inverse should be identity
165        let x_rot = Affine3D::apply_rot(&rot, &x)?;
166        let x_back = Affine3D::apply_rot_inv(&rot, &x_rot)?;
167        let diff = x_back.sub(&x)?.sqr()?.sum_all()?.to_scalar::<f32>()?;
168        assert!(diff < 1e-5, "rot/rot_inv roundtrip failed: {}", diff);
169        Ok(())
170    }
171
172    #[test]
173    fn test_build_affine3d_from_coordinates() -> Result<()> {
174        let device = &Device::Cpu;
175        // Simple linear backbone: N=0,0,0; CA=1,0,0; C=2,0,0 — degenerate but non-zero
176        let n = Tensor::new(&[[[0f32, 0., 0.], [1., 0., 0.], [2., 0., 0.]]], device)?;
177        let ca = Tensor::new(&[[[1f32, 0., 0.], [2., 0., 0.], [3., 0., 0.]]], device)?;
178        let c = Tensor::new(&[[[2f32, 0., 0.], [3., 0., 0.], [4., 0., 0.]]], device)?;
179        // Stack into (1, 3, 3, 3) = (B, L, atom, xyz)
180        let n_e = n.unsqueeze(2)?;
181        let ca_e = ca.unsqueeze(2)?;
182        let c_e = c.unsqueeze(2)?;
183        let coords = Tensor::cat(&[&n_e, &ca_e, &c_e], 2)?; // (1, 3, 3, 3)
184
185        let (_affine, mask) = Affine3D::build_affine3d_from_coordinates(&coords)?;
186        // mask should be 0 because all vectors are collinear (cross product ≈ 0)
187        // but the lengths themselves are non-zero so the mask should be 1
188        let mask_sum = mask.to_dtype(DType::F32)?.sum_all()?.to_scalar::<f32>()?;
189        // We only check it runs without error and returns a reasonable mask
190        assert!(mask_sum >= 0.0);
191        Ok(())
192    }
193}