Skip to main content

molframe_core/structure/
diff.rs

1//! Semantic comparison of immutable structure snapshots.
2
3use super::{Structure, UnitCell};
4use crate::index::{AtomIndex, ChainIndex, ModelIndex, ResidueIndex};
5
6/// A value before and after a semantic change.
7#[derive(Clone, Debug, PartialEq)]
8pub struct ValueDifference<T> {
9    /// Value in the left-hand structure.
10    pub left: T,
11    /// Value in the right-hand structure.
12    pub right: T,
13}
14
15/// A row count before and after a structural change.
16pub type CountDifference = ValueDifference<usize>;
17
18/// Entry-level metadata changes.
19#[derive(Clone, Debug, Default, PartialEq)]
20pub struct MetadataDifference {
21    /// Deposited entry identifier.
22    pub id: Option<ValueDifference<Option<Box<str>>>>,
23    /// Entry title.
24    pub title: Option<ValueDifference<Option<Box<str>>>>,
25    /// Experimental method.
26    pub method: Option<ValueDifference<Option<Box<str>>>>,
27    /// Reported resolution in ångström.
28    pub resolution: Option<ValueDifference<Option<f32>>>,
29    /// Crystallographic unit cell.
30    pub cell: Option<ValueDifference<Option<UnitCell>>>,
31}
32
33/// Explicit controls for semantic structure comparison.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct StructureDifferenceOptions {
36    /// Maximum Euclidean coordinate displacement considered unchanged, in ångström.
37    pub coordinate_tolerance: f32,
38}
39
40/// Why a structure comparison could not be performed.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum DifferenceError {
43    /// Coordinate tolerance must be finite and non-negative.
44    InvalidCoordinateTolerance,
45}
46
47impl std::fmt::Display for DifferenceError {
48    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::InvalidCoordinateTolerance => {
51                formatter.write_str("coordinate tolerance must be finite and non-negative")
52            }
53        }
54    }
55}
56
57impl std::error::Error for DifferenceError {}
58
59/// Complete semantic difference summary for two normalised structures.
60#[derive(Clone, Debug, PartialEq)]
61pub struct StructureDifference {
62    /// Entry-level metadata that changed.
63    pub metadata: MetadataDifference,
64    /// Model counts when different.
65    pub models: Option<CountDifference>,
66    /// Entity counts when different.
67    pub entities: Option<CountDifference>,
68    /// Chain counts when different.
69    pub chains: Option<CountDifference>,
70    /// Residue counts when different.
71    pub residues: Option<CountDifference>,
72    /// Atom counts when different.
73    pub atoms: Option<CountDifference>,
74    /// Bond counts when different.
75    pub bonds: Option<CountDifference>,
76    /// Corresponding entity rows with different semantic values.
77    pub changed_entities: usize,
78    /// Corresponding chain rows with different semantic values.
79    pub changed_chains: usize,
80    /// Corresponding residue rows with different semantic values.
81    pub changed_residues: usize,
82    /// Corresponding atom rows with different non-coordinate values.
83    pub changed_atoms: usize,
84    /// Corresponding bond rows that differ.
85    pub changed_bonds: usize,
86    /// Corresponding model/atom positions beyond the requested tolerance.
87    pub changed_positions: usize,
88    /// Largest finite corresponding coordinate displacement, in ångström.
89    pub maximum_displacement: Option<f32>,
90}
91
92impl StructureDifference {
93    /// Whether the two normalised structures are semantically equivalent.
94    #[must_use]
95    pub fn is_empty(&self) -> bool {
96        self.metadata == MetadataDifference::default()
97            && self.models.is_none()
98            && self.entities.is_none()
99            && self.chains.is_none()
100            && self.residues.is_none()
101            && self.atoms.is_none()
102            && self.bonds.is_none()
103            && self.changed_entities == 0
104            && self.changed_chains == 0
105            && self.changed_residues == 0
106            && self.changed_atoms == 0
107            && self.changed_bonds == 0
108            && self.changed_positions == 0
109    }
110}
111
112/// Compares all normalised structure data with explicit coordinate tolerance.
113///
114/// # Errors
115///
116/// Returns [`DifferenceError::InvalidCoordinateTolerance`] when the tolerance
117/// is negative or not finite.
118pub fn structure_difference(
119    left: &Structure,
120    right: &Structure,
121    options: StructureDifferenceOptions,
122) -> Result<StructureDifference, DifferenceError> {
123    if !options.coordinate_tolerance.is_finite() || options.coordinate_tolerance < 0.0 {
124        return Err(DifferenceError::InvalidCoordinateTolerance);
125    }
126
127    let (changed_positions, maximum_displacement) =
128        coordinate_changes(left, right, options.coordinate_tolerance);
129    Ok(StructureDifference {
130        metadata: metadata_difference(left, right),
131        models: different_count(left.model_count(), right.model_count()),
132        entities: different_count(left.entity_count(), right.entity_count()),
133        chains: different_count(left.chain_count(), right.chain_count()),
134        residues: different_count(left.residue_count(), right.residue_count()),
135        atoms: different_count(left.atom_count() as usize, right.atom_count() as usize),
136        bonds: different_count(left.data().bonds.len(), right.data().bonds.len()),
137        changed_entities: changed_entity_count(left, right),
138        changed_chains: changed_chain_count(left, right),
139        changed_residues: changed_residue_count(left, right),
140        changed_atoms: changed_atom_count(left, right),
141        changed_bonds: left
142            .data()
143            .bonds
144            .iter()
145            .zip(right.data().bonds.iter())
146            .filter(|(left, right)| left != right)
147            .count(),
148        changed_positions,
149        maximum_displacement,
150    })
151}
152
153fn changed_entity_count(left: &Structure, right: &Structure) -> usize {
154    let left_table = &left.data().topology.entities;
155    let right_table = &right.data().topology.entities;
156    left_table
157        .iter()
158        .zip(right_table.iter())
159        .filter(|(left_index, right_index)| {
160            left_table.kind(*left_index) != right_table.kind(*right_index)
161                || resolved(left, left_table.id(*left_index))
162                    != resolved(right, right_table.id(*right_index))
163                || resolved(left, left_table.description(*left_index))
164                    != resolved(right, right_table.description(*right_index))
165                || resolved_sequence(left, left_table.canonical_sequence(*left_index))
166                    != resolved_sequence(right, right_table.canonical_sequence(*right_index))
167        })
168        .count()
169}
170
171fn changed_chain_count(left: &Structure, right: &Structure) -> usize {
172    (0..left.chain_count().min(right.chain_count()))
173        .filter(|position| {
174            let Ok(position) = u32::try_from(*position) else {
175                return true;
176            };
177            let index = ChainIndex::new(position);
178            let left_chain = left.chain(index);
179            let right_chain = right.chain(index);
180            match (left_chain, right_chain) {
181                (Some(left_chain), Some(right_chain)) => {
182                    left_chain.label() != right_chain.label()
183                        || left_chain.auth_label() != right_chain.auth_label()
184                        || left_chain.entity() != right_chain.entity()
185                        || left_chain.polymer_kind() != right_chain.polymer_kind()
186                }
187                _ => true,
188            }
189        })
190        .count()
191}
192
193fn changed_residue_count(left: &Structure, right: &Structure) -> usize {
194    (0..left.residue_count().min(right.residue_count()))
195        .filter(|position| {
196            let Ok(position) = u32::try_from(*position) else {
197                return true;
198            };
199            let index = ResidueIndex::new(position);
200            let left_residue = left.residue(index);
201            let right_residue = right.residue(index);
202            match (left_residue, right_residue) {
203                (Some(left_residue), Some(right_residue)) => {
204                    left_residue.name() != right_residue.name()
205                        || left_residue.auth_name() != right_residue.auth_name()
206                        || left_residue.label_seq_id() != right_residue.label_seq_id()
207                        || left_residue.auth_seq_id() != right_residue.auth_seq_id()
208                        || left_residue.ins_code() != right_residue.ins_code()
209                        || left_residue.is_het() != right_residue.is_het()
210                }
211                _ => true,
212            }
213        })
214        .count()
215}
216
217fn changed_atom_count(left: &Structure, right: &Structure) -> usize {
218    (0..left.atom_count().min(right.atom_count()))
219        .filter(|position| {
220            let left_atom = left.atom(AtomIndex::new(*position));
221            let right_atom = right.atom(AtomIndex::new(*position));
222            match (left_atom, right_atom) {
223                (Some(left_atom), Some(right_atom)) => {
224                    left_atom.name() != right_atom.name()
225                        || left_atom.auth_name() != right_atom.auth_name()
226                        || left_atom.component_name() != right_atom.component_name()
227                        || left_atom.element() != right_atom.element()
228                        || left_atom.alt_label() != right_atom.alt_label()
229                        || left_atom.b_factor() != right_atom.b_factor()
230                        || left_atom.occupancy() != right_atom.occupancy()
231                        || left_atom.formal_charge() != right_atom.formal_charge()
232                        || left_atom.atom_site_id() != right_atom.atom_site_id()
233                }
234                _ => true,
235            }
236        })
237        .count()
238}
239
240fn coordinate_changes(left: &Structure, right: &Structure, tolerance: f32) -> (usize, Option<f32>) {
241    let mut changed = 0usize;
242    let mut maximum: Option<f32> = None;
243    for model in 0..left.model_count().min(right.model_count()) {
244        let Ok(model) = u32::try_from(model) else {
245            break;
246        };
247        let index = ModelIndex::new(model);
248        let left_snapshot = left.model_snapshot(index);
249        let right_snapshot = right.model_snapshot(index);
250        let left_positions: &[[f32; 3]] = match &left_snapshot {
251            Some((structure, local)) => structure
252                .model_positions(*local)
253                .map_or(&[], |positions| positions),
254            None => &[],
255        };
256        let right_positions: &[[f32; 3]] = match &right_snapshot {
257            Some((structure, local)) => structure
258                .model_positions(*local)
259                .map_or(&[], |positions| positions),
260            None => &[],
261        };
262        for (left_position, right_position) in left_positions.iter().zip(right_positions) {
263            let displacement = squared_distance(*left_position, *right_position).sqrt();
264            if !displacement.is_finite() || displacement > tolerance {
265                changed += 1;
266            }
267            if displacement.is_finite() {
268                maximum = Some(maximum.map_or(displacement, |value| value.max(displacement)));
269            }
270        }
271        changed += left_positions.len().abs_diff(right_positions.len());
272    }
273    (changed, maximum)
274}
275
276fn metadata_difference(left: &Structure, right: &Structure) -> MetadataDifference {
277    let left_entry = &left.data().entry;
278    let right_entry = &right.data().entry;
279    MetadataDifference {
280        id: different_value(left_entry.id.clone(), right_entry.id.clone()),
281        title: different_value(left_entry.title.clone(), right_entry.title.clone()),
282        method: different_value(left_entry.method.clone(), right_entry.method.clone()),
283        resolution: different_value(left_entry.resolution, right_entry.resolution),
284        cell: different_value(left.data().cell, right.data().cell),
285    }
286}
287
288fn different_count(left: usize, right: usize) -> Option<CountDifference> {
289    different_value(left, right)
290}
291
292fn different_value<T: PartialEq>(left: T, right: T) -> Option<ValueDifference<T>> {
293    (left != right).then_some(ValueDifference { left, right })
294}
295
296fn resolved(structure: &Structure, symbol: Option<crate::SymbolId>) -> Option<&str> {
297    symbol.and_then(|value| structure.resolve(value))
298}
299
300fn resolved_sequence<'a>(
301    structure: &'a Structure,
302    sequence: &'a [crate::SymbolId],
303) -> Vec<Option<&'a str>> {
304    sequence
305        .iter()
306        .map(|symbol| structure.resolve(*symbol))
307        .collect()
308}
309
310fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
311    left.into_iter()
312        .zip(right)
313        .map(|(left, right)| {
314            let delta = left - right;
315            delta * delta
316        })
317        .sum()
318}
319
320#[cfg(test)]
321#[path = "diff_tests.rs"]
322mod tests;