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_bond, right_bond)| {
147                // The connection class is an interned identifier, so two
148                // independently built structures number the same string
149                // differently. Resolve it before comparing, as every other
150                // interned field here does; a raw comparison would call two
151                // identical files different and two different files identical.
152                left_bond.atom_a != right_bond.atom_a
153                    || left_bond.atom_b != right_bond.atom_b
154                    || left_bond.order != right_bond.order
155                    || left_bond.provenance != right_bond.provenance
156                    || resolved(left, left_bond.kind.into())
157                        != resolved(right, right_bond.kind.into())
158            })
159            .count(),
160        changed_positions,
161        maximum_displacement,
162    })
163}
164
165fn changed_entity_count(left: &Structure, right: &Structure) -> usize {
166    let left_table = &left.data().topology.entities;
167    let right_table = &right.data().topology.entities;
168    left_table
169        .iter()
170        .zip(right_table.iter())
171        .filter(|(left_index, right_index)| {
172            left_table.kind(*left_index) != right_table.kind(*right_index)
173                || resolved(left, left_table.id(*left_index))
174                    != resolved(right, right_table.id(*right_index))
175                || resolved(left, left_table.description(*left_index))
176                    != resolved(right, right_table.description(*right_index))
177                || resolved_sequence(left, left_table.canonical_sequence(*left_index))
178                    != resolved_sequence(right, right_table.canonical_sequence(*right_index))
179        })
180        .count()
181}
182
183fn changed_chain_count(left: &Structure, right: &Structure) -> usize {
184    (0..left.chain_count().min(right.chain_count()))
185        .filter(|position| {
186            let Ok(position) = u32::try_from(*position) else {
187                return true;
188            };
189            let index = ChainIndex::new(position);
190            let left_chain = left.chain(index);
191            let right_chain = right.chain(index);
192            match (left_chain, right_chain) {
193                (Some(left_chain), Some(right_chain)) => {
194                    left_chain.label() != right_chain.label()
195                        || left_chain.auth_label() != right_chain.auth_label()
196                        || left_chain.entity() != right_chain.entity()
197                        || left_chain.polymer_kind() != right_chain.polymer_kind()
198                }
199                _ => true,
200            }
201        })
202        .count()
203}
204
205fn changed_residue_count(left: &Structure, right: &Structure) -> usize {
206    (0..left.residue_count().min(right.residue_count()))
207        .filter(|position| {
208            let Ok(position) = u32::try_from(*position) else {
209                return true;
210            };
211            let index = ResidueIndex::new(position);
212            let left_residue = left.residue(index);
213            let right_residue = right.residue(index);
214            match (left_residue, right_residue) {
215                (Some(left_residue), Some(right_residue)) => {
216                    left_residue.name() != right_residue.name()
217                        || left_residue.auth_name() != right_residue.auth_name()
218                        || left_residue.label_seq_id() != right_residue.label_seq_id()
219                        || left_residue.auth_seq_id() != right_residue.auth_seq_id()
220                        || left_residue.ins_code() != right_residue.ins_code()
221                        || left_residue.is_het() != right_residue.is_het()
222                }
223                _ => true,
224            }
225        })
226        .count()
227}
228
229fn changed_atom_count(left: &Structure, right: &Structure) -> usize {
230    (0..left.atom_count().min(right.atom_count()))
231        .filter(|position| {
232            let left_atom = left.atom(AtomIndex::new(*position));
233            let right_atom = right.atom(AtomIndex::new(*position));
234            match (left_atom, right_atom) {
235                (Some(left_atom), Some(right_atom)) => {
236                    left_atom.name() != right_atom.name()
237                        || left_atom.auth_name() != right_atom.auth_name()
238                        || left_atom.component_name() != right_atom.component_name()
239                        || left_atom.element() != right_atom.element()
240                        || left_atom.alt_label() != right_atom.alt_label()
241                        || left_atom.b_factor() != right_atom.b_factor()
242                        || left_atom.occupancy() != right_atom.occupancy()
243                        || left_atom.formal_charge() != right_atom.formal_charge()
244                        || left_atom.atom_site_id() != right_atom.atom_site_id()
245                }
246                _ => true,
247            }
248        })
249        .count()
250}
251
252fn coordinate_changes(left: &Structure, right: &Structure, tolerance: f32) -> (usize, Option<f32>) {
253    let mut changed = 0usize;
254    let mut maximum: Option<f32> = None;
255    for model in 0..left.model_count().min(right.model_count()) {
256        let Ok(model) = u32::try_from(model) else {
257            break;
258        };
259        let index = ModelIndex::new(model);
260        let left_snapshot = left.model_snapshot(index);
261        let right_snapshot = right.model_snapshot(index);
262        let left_positions: &[[f32; 3]] = match &left_snapshot {
263            Some((structure, local)) => structure
264                .model_positions(*local)
265                .map_or(&[], |positions| positions),
266            None => &[],
267        };
268        let right_positions: &[[f32; 3]] = match &right_snapshot {
269            Some((structure, local)) => structure
270                .model_positions(*local)
271                .map_or(&[], |positions| positions),
272            None => &[],
273        };
274        for (left_position, right_position) in left_positions.iter().zip(right_positions) {
275            let displacement = squared_distance(*left_position, *right_position).sqrt();
276            if !displacement.is_finite() || displacement > tolerance {
277                changed += 1;
278            }
279            if displacement.is_finite() {
280                maximum = Some(maximum.map_or(displacement, |value| value.max(displacement)));
281            }
282        }
283        changed += left_positions.len().abs_diff(right_positions.len());
284    }
285    (changed, maximum)
286}
287
288fn metadata_difference(left: &Structure, right: &Structure) -> MetadataDifference {
289    let left_entry = &left.data().entry;
290    let right_entry = &right.data().entry;
291    MetadataDifference {
292        id: different_value(left_entry.id.clone(), right_entry.id.clone()),
293        title: different_value(left_entry.title.clone(), right_entry.title.clone()),
294        method: different_value(left_entry.method.clone(), right_entry.method.clone()),
295        resolution: different_value(left_entry.resolution, right_entry.resolution),
296        cell: different_value(left.data().cell, right.data().cell),
297    }
298}
299
300fn different_count(left: usize, right: usize) -> Option<CountDifference> {
301    different_value(left, right)
302}
303
304fn different_value<T: PartialEq>(left: T, right: T) -> Option<ValueDifference<T>> {
305    (left != right).then_some(ValueDifference { left, right })
306}
307
308fn resolved(structure: &Structure, symbol: Option<crate::SymbolId>) -> Option<&str> {
309    symbol.and_then(|value| structure.resolve(value))
310}
311
312fn resolved_sequence<'a>(
313    structure: &'a Structure,
314    sequence: &'a [crate::SymbolId],
315) -> Vec<Option<&'a str>> {
316    sequence
317        .iter()
318        .map(|symbol| structure.resolve(*symbol))
319        .collect()
320}
321
322fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
323    left.into_iter()
324        .zip(right)
325        .map(|(left, right)| {
326            let delta = left - right;
327            delta * delta
328        })
329        .sum()
330}
331
332#[cfg(test)]
333#[path = "diff_tests.rs"]
334mod tests;