Skip to main content

xtalforge_core/
compare.rs

1#![allow(clippy::indexing_slicing, clippy::needless_range_loop)] // fixed-size numeric kernels
2
3//! Comparison primitives: Kabsch alignment, periodic RMSD and interpolation.
4//!
5//! The structure matcher of Task 9.1 lives in the symmetry crate, where the
6//! Niggli reduction is available. This module holds the geometry that the
7//! matcher, the binding surfaces and the interpolation share.
8
9use serde::{Deserialize, Serialize};
10
11use crate::{Error, Lattice, Structure};
12
13/// A rigid alignment of one point set onto another.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct Alignment {
16    /// Rotation matrix in Cartesian coordinates.
17    pub rotation: [[f64; 3]; 3],
18    /// Translation applied after the rotation.
19    pub translation: [f64; 3],
20    /// Root mean square displacement in Angstrom.
21    pub rmsd: f64,
22    /// The first point set after the rotation and translation.
23    pub aligned: Vec<[f64; 3]>,
24}
25
26/// Optimal rigid alignment of `points` onto `reference` (Kabsch, 1976).
27///
28/// The algorithm uses Horn's quaternion form, so no external linear-algebra
29/// routine is needed and the result is deterministic.
30///
31/// # Errors
32///
33/// Returns [`Error::InvalidInput`] for fewer than two points, different
34/// lengths or non-finite coordinates.
35pub fn kabsch_alignment(points: &[[f64; 3]], reference: &[[f64; 3]]) -> Result<Alignment, Error> {
36    if points.len() != reference.len() {
37        return Err(Error::InvalidInput(format!(
38            "alignment needs the same number of points, got {} and {}",
39            points.len(),
40            reference.len()
41        )));
42    }
43    if points.len() < 2 {
44        return Err(Error::InvalidInput(
45            "alignment needs at least two points".to_owned(),
46        ));
47    }
48    if !points
49        .iter()
50        .chain(reference)
51        .flatten()
52        .all(|value| value.is_finite())
53    {
54        return Err(Error::InvalidInput(
55            "alignment points must be finite".to_owned(),
56        ));
57    }
58    let first_centre = centroid(points);
59    let second_centre = centroid(reference);
60    let mut covariance = [[0.0; 3]; 3];
61    for (point, target) in points.iter().zip(reference) {
62        let a = subtract(*point, first_centre);
63        let b = subtract(*target, second_centre);
64        for row in 0..3 {
65            for column in 0..3 {
66                if let (Some(value), Some(other)) = (a.get(row), b.get(column))
67                    && let Some(entry) = covariance.get_mut(row).and_then(|r| r.get_mut(column))
68                {
69                    *entry += value * other;
70                }
71            }
72        }
73    }
74    let rotation = rotation_from_covariance(covariance);
75    let rotated_centre = apply(rotation, first_centre);
76    let translation = [
77        second_centre[0] - rotated_centre[0],
78        second_centre[1] - rotated_centre[1],
79        second_centre[2] - rotated_centre[2],
80    ];
81    let mut aligned = Vec::with_capacity(points.len());
82    let mut squared = 0.0;
83    for (point, target) in points.iter().zip(reference) {
84        let moved = apply(rotation, *point);
85        let moved = [
86            moved[0] + translation[0],
87            moved[1] + translation[1],
88            moved[2] + translation[2],
89        ];
90        let delta = subtract(moved, *target);
91        squared += dot(delta, delta);
92        aligned.push(moved);
93    }
94    Ok(Alignment {
95        rotation,
96        translation,
97        rmsd: (squared / points.len() as f64).sqrt(),
98        aligned,
99    })
100}
101
102/// Periodic RMS displacement of two matched P1 cells.
103///
104/// `mapping[i]` is the index in `second` that corresponds to P1 atom `i` of
105/// `first`. Each pair is measured with the minimum image of the first cell.
106///
107/// # Errors
108///
109/// Returns [`Error::InvalidInput`] for an incomplete mapping, different
110/// lattice volumes or an empty mapping.
111pub fn periodic_rmsd(
112    first: &Structure,
113    second: &Structure,
114    mapping: &[usize],
115) -> Result<f64, Error> {
116    if mapping.is_empty() {
117        return Err(Error::InvalidInput(
118            "periodic RMSD needs at least one mapped atom".to_owned(),
119        ));
120    }
121    if !first.lattice().almost_equal(second.lattice(), 1e-6) {
122        return Err(Error::InvalidInput(
123            "periodic RMSD needs the same cell; use the structure matcher first".to_owned(),
124        ));
125    }
126    let first_cell = first.p1()?;
127    let second_cell = second.p1()?;
128    if mapping.len() != first_cell.atoms.len() {
129        return Err(Error::InvalidInput(format!(
130            "mapping has {} entries for {} atoms",
131            mapping.len(),
132            first_cell.atoms.len()
133        )));
134    }
135    let mut squared = 0.0;
136    for (atom, &target) in first_cell.atoms.iter().zip(mapping) {
137        let Some(other) = second_cell.atoms.get(target) else {
138            return Err(Error::InvalidInput(format!(
139                "mapping index {target} is out of range"
140            )));
141        };
142        let delta = subtract(other.frac, atom.frac);
143        let (_, distance) = first.lattice().minimum_image(delta);
144        squared += distance * distance;
145    }
146    Ok((squared / mapping.len() as f64).sqrt())
147}
148
149/// Linear interpolation of two structures with the same atom order.
150///
151/// `images` intermediate frames are returned; the endpoints are not included.
152/// Fractional differences take the minimum image across the cell boundary.
153///
154/// # Errors
155///
156/// Returns [`Error::InvalidInput`] for `images == 0`, different atom counts or
157/// elements, or an inconsistent cell.
158pub fn interpolate(
159    first: &Structure,
160    second: &Structure,
161    images: u32,
162) -> Result<Vec<Structure>, Error> {
163    if images == 0 {
164        return Err(Error::InvalidInput(
165            "interpolation needs at least one image".to_owned(),
166        ));
167    }
168    let start = first.p1()?;
169    let end = second.p1()?;
170    if start.atoms.len() != end.atoms.len() {
171        return Err(Error::InvalidInput(format!(
172            "interpolation needs the same atom count, got {} and {}",
173            start.atoms.len(),
174            end.atoms.len()
175        )));
176    }
177    for (a, b) in start.atoms.iter().zip(&end.atoms) {
178        if a.element != b.element {
179            return Err(Error::InvalidInput(format!(
180                "atom {} is {} in the first structure and {} in the second",
181                a.label, a.element, b.element
182            )));
183        }
184    }
185    let matrix = start.lattice.matrix();
186    let end_matrix = end.lattice.matrix();
187    let mut frames = Vec::with_capacity(images as usize);
188    for frame in 1..=images {
189        let t = f64::from(frame) / f64::from(images + 1);
190        let mut lattice_rows = [[0.0; 3]; 3];
191        for row in 0..3 {
192            for column in 0..3 {
193                let a = matrix[row][column];
194                let b = end_matrix[row][column];
195                lattice_rows[row][column] = a + (b - a) * t;
196            }
197        }
198        let lattice = Lattice::new(lattice_rows)?;
199        let mut builder = crate::StructureBuilder::new(first.name(), lattice);
200        for (a, b) in start.atoms.iter().zip(&end.atoms) {
201            let (delta, _) = start.lattice.minimum_image(subtract(b.frac, a.frac));
202            let frac = [
203                a.frac[0] + delta[0] * t,
204                a.frac[1] + delta[1] * t,
205                a.frac[2] + delta[2] * t,
206            ];
207            let site = crate::Site::new(a.label.clone(), &a.element, Lattice::wrap(frac))?;
208            builder = builder.site(site.with_occupancy(a.occupancy)?);
209        }
210        frames.push(builder.build()?);
211    }
212    Ok(frames)
213}
214
215/// Rotation matrix from Horn's 4x4 quaternion matrix.
216fn rotation_from_covariance(s: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
217    let [[sxx, sxy, sxz], [syx, syy, syz], [szx, szy, szz]] = s;
218    let matrix = [
219        [sxx + syy + szz, syz - szy, szx - sxz, sxy - syx],
220        [syz - szy, sxx - syy - szz, sxy + syx, szx + sxz],
221        [szx - sxz, sxy + syx, -sxx + syy - szz, syz + szy],
222        [sxy - syx, szx + sxz, syz + szy, -sxx - syy + szz],
223    ];
224    let (values, vectors) = jacobi4(matrix);
225    let mut best = 0;
226    for index in 1..4 {
227        if values.get(index).copied().unwrap_or(f64::NEG_INFINITY)
228            > values.get(best).copied().unwrap_or(f64::NEG_INFINITY)
229        {
230            best = index;
231        }
232    }
233    let quaternion = [
234        vectors[0][best],
235        vectors[1][best],
236        vectors[2][best],
237        vectors[3][best],
238    ];
239    quaternion_matrix(quaternion)
240}
241
242fn jacobi4(mut matrix: [[f64; 4]; 4]) -> ([f64; 4], [[f64; 4]; 4]) {
243    let mut vectors = [
244        [1.0, 0.0, 0.0, 0.0],
245        [0.0, 1.0, 0.0, 0.0],
246        [0.0, 0.0, 1.0, 0.0],
247        [0.0, 0.0, 0.0, 1.0],
248    ];
249    for _ in 0..64 {
250        let mut p = 0;
251        let mut q = 1;
252        let mut largest = 0.0;
253        for row in 0..4 {
254            for column in (row + 1)..4 {
255                let value = matrix[row][column].abs();
256                if value > largest {
257                    largest = value;
258                    p = row;
259                    q = column;
260                }
261            }
262        }
263        if largest < 1e-14 {
264            break;
265        }
266        let theta = (matrix[q][q] - matrix[p][p]) / (2.0 * matrix[p][q]);
267        let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
268        let c = 1.0 / (t * t + 1.0).sqrt();
269        let s = t * c;
270        let mut next = matrix;
271        for k in 0..4 {
272            next[p][k] = c * matrix[p][k] - s * matrix[q][k];
273            next[q][k] = s * matrix[p][k] + c * matrix[q][k];
274        }
275        matrix = next;
276        let mut transposed = matrix;
277        for k in 0..4 {
278            transposed[k][p] = c * matrix[k][p] - s * matrix[k][q];
279            transposed[k][q] = s * matrix[k][p] + c * matrix[k][q];
280        }
281        matrix = transposed;
282        for k in 0..4 {
283            let a = vectors[k][p];
284            let b = vectors[k][q];
285            vectors[k][p] = c * a - s * b;
286            vectors[k][q] = s * a + c * b;
287        }
288    }
289    (
290        [matrix[0][0], matrix[1][1], matrix[2][2], matrix[3][3]],
291        vectors,
292    )
293}
294
295fn quaternion_matrix(quaternion: [f64; 4]) -> [[f64; 3]; 3] {
296    let norm = (quaternion[0] * quaternion[0]
297        + quaternion[1] * quaternion[1]
298        + quaternion[2] * quaternion[2]
299        + quaternion[3] * quaternion[3])
300        .sqrt();
301    if norm < 1e-12 {
302        return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
303    }
304    let [w, x, y, z] = quaternion.map(|value| value / norm);
305    [
306        [
307            1.0 - 2.0 * (y * y + z * z),
308            2.0 * (x * y - z * w),
309            2.0 * (x * z + y * w),
310        ],
311        [
312            2.0 * (x * y + z * w),
313            1.0 - 2.0 * (x * x + z * z),
314            2.0 * (y * z - x * w),
315        ],
316        [
317            2.0 * (x * z - y * w),
318            2.0 * (y * z + x * w),
319            1.0 - 2.0 * (x * x + y * y),
320        ],
321    ]
322}
323
324fn centroid(points: &[[f64; 3]]) -> [f64; 3] {
325    let mut total = [0.0; 3];
326    for point in points {
327        total[0] += point[0];
328        total[1] += point[1];
329        total[2] += point[2];
330    }
331    let count = points.len().max(1) as f64;
332    [total[0] / count, total[1] / count, total[2] / count]
333}
334
335fn apply(matrix: [[f64; 3]; 3], point: [f64; 3]) -> [f64; 3] {
336    [
337        matrix[0][0] * point[0] + matrix[0][1] * point[1] + matrix[0][2] * point[2],
338        matrix[1][0] * point[0] + matrix[1][1] * point[1] + matrix[1][2] * point[2],
339        matrix[2][0] * point[0] + matrix[2][1] * point[1] + matrix[2][2] * point[2],
340    ]
341}
342
343fn subtract(first: [f64; 3], second: [f64; 3]) -> [f64; 3] {
344    [
345        first[0] - second[0],
346        first[1] - second[1],
347        first[2] - second[2],
348    ]
349}
350
351fn dot(first: [f64; 3], second: [f64; 3]) -> f64 {
352    first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
353}