Skip to main content

molpack/
validation.rs

1use molrs::types::F;
2use std::collections::HashMap;
3use std::env;
4
5use std::sync::Arc;
6
7use crate::numerics::numeric_controls;
8use crate::restraint::AtomRestraint;
9use crate::target::Target;
10
11/// Quantified violation metrics for one packed configuration.
12#[derive(Debug, Clone, Copy, Default)]
13pub struct ViolationMetrics {
14    /// Maximum inter-molecule distance violation in Angstrom (tolerance - distance).
15    pub max_distance_violation: F,
16    /// Maximum geometric-constraint penalty value (Packmol restraint function value).
17    pub max_constraint_penalty: F,
18    /// Number of violating inter-molecule atom pairs.
19    pub violating_pairs: usize,
20    /// Number of atoms involved in any violation.
21    pub violating_atoms: usize,
22}
23
24/// Validation report for a packing run.
25#[derive(Debug, Clone)]
26pub struct ValidationReport {
27    pub expected_atoms: usize,
28    pub actual_atoms: usize,
29    pub expected_molecules: usize,
30    /// Atom count exactly matches expanded target specification.
31    pub atom_count_ok: bool,
32    /// Molecule count exactly matches expanded target specification.
33    pub molecule_count_ok: bool,
34    /// Distance violations satisfy precision threshold.
35    pub distance_ok: bool,
36    /// Constraint violations satisfy precision threshold.
37    pub constraints_ok: bool,
38    /// Detailed metrics.
39    pub metrics: ViolationMetrics,
40}
41
42impl ValidationReport {
43    pub fn is_valid(&self) -> bool {
44        self.atom_count_ok && self.molecule_count_ok && self.distance_ok && self.constraints_ok
45    }
46}
47
48#[derive(Debug, Clone)]
49struct ExpandedMol<'a> {
50    target: &'a Target,
51    start: usize,
52    end: usize,
53    molecule_id: usize,
54}
55
56#[derive(Clone, Default)]
57struct AtomRestraints {
58    restraints: Vec<Arc<dyn AtomRestraint>>,
59}
60
61/// Validate packed coordinates against target specification.
62pub fn validate_from_targets(
63    targets: &[Target],
64    coordinates: &[[F; 3]],
65    tolerance: F,
66    precision: F,
67) -> ValidationReport {
68    let expanded = expand_targets(targets);
69    let expected_atoms = expanded.last().map_or(0usize, |m| m.end);
70    let expected_molecules = expanded.len();
71
72    let (constraint_penalty, constraint_violating_atoms) =
73        constraint_metrics(&expanded, coordinates, precision);
74    let (distance_violation, pair_violations, distance_violating_atoms) =
75        distance_metrics(&expanded, coordinates, tolerance, precision);
76
77    let violating_atoms = union_count(
78        coordinates.len(),
79        &constraint_violating_atoms,
80        &distance_violating_atoms,
81    );
82
83    ValidationReport {
84        expected_atoms,
85        actual_atoms: coordinates.len(),
86        expected_molecules,
87        atom_count_ok: coordinates.len() == expected_atoms,
88        molecule_count_ok: expected_molecules > 0,
89        distance_ok: distance_violation <= precision,
90        constraints_ok: constraint_penalty <= precision,
91        metrics: ViolationMetrics {
92            max_distance_violation: distance_violation,
93            max_constraint_penalty: constraint_penalty,
94            violating_pairs: pair_violations,
95            violating_atoms,
96        },
97    }
98}
99
100fn expand_targets(targets: &[Target]) -> Vec<ExpandedMol<'_>> {
101    // Molecule order MUST match the assembled-frame coordinate layout that
102    // `PackResult::positions()` returns — which is **declared target order**
103    // (a fixed target keeps its declared slot; e.g. a `fixed` protein declared
104    // first stays first). Slicing the coordinates in a free-first order instead
105    // would map a declared-first fixed solute's atoms onto the wrong molecules
106    // and flag its own internal bonds (~1.5 Å) as inter-molecule overlaps.
107    let mut expanded = Vec::new();
108    let mut cursor = 0usize;
109    let mut mol_id = 0usize;
110
111    for target in targets.iter() {
112        let nmols = if target.fixed_at.is_some() {
113            1
114        } else {
115            target.count
116        };
117        for _ in 0..nmols {
118            let start = cursor;
119            let end = start + target.natoms();
120            expanded.push(ExpandedMol {
121                target,
122                start,
123                end,
124                molecule_id: mol_id,
125            });
126            cursor = end;
127            mol_id += 1;
128        }
129    }
130
131    expanded
132}
133
134fn atom_restraints(target: &Target) -> Vec<AtomRestraints> {
135    let mut per_atom = vec![
136        AtomRestraints {
137            restraints: target.molecule_restraints.clone(),
138        };
139        target.natoms()
140    ];
141
142    for (indices, restraint) in &target.atom_restraints {
143        for &idx in indices {
144            if let Some(slot) = per_atom.get_mut(idx) {
145                slot.restraints.push(Arc::clone(restraint));
146            }
147        }
148    }
149    per_atom
150}
151
152fn constraint_metrics(
153    expanded: &[ExpandedMol<'_>],
154    coordinates: &[[F; 3]],
155    precision: F,
156) -> (F, Vec<bool>) {
157    let mut max_penalty = 0.0 as F;
158    let mut violating_atoms = vec![false; coordinates.len()];
159    let penalty_eps = precision;
160
161    for mol in expanded {
162        let per_atom = atom_restraints(mol.target);
163        for (local_i, atom_i) in (mol.start..mol.end).enumerate() {
164            let pos = coordinates[atom_i];
165            let mut atom_penalty = 0.0 as F;
166            for r in &per_atom[local_i].restraints {
167                atom_penalty += r.f(&pos, 1.0, crate::numerics::DEFAULT_SCALE2);
168            }
169            if atom_penalty > max_penalty {
170                max_penalty = atom_penalty;
171            }
172            if atom_penalty > penalty_eps {
173                violating_atoms[atom_i] = true;
174            }
175        }
176    }
177
178    (max_penalty, violating_atoms)
179}
180
181fn distance_metrics(
182    expanded: &[ExpandedMol<'_>],
183    coordinates: &[[F; 3]],
184    tolerance: F,
185    precision: F,
186) -> (F, usize, Vec<bool>) {
187    if coordinates.is_empty() {
188        return (0.0, 0, Vec::new());
189    }
190
191    let mut molecule_of_atom = vec![0usize; coordinates.len()];
192    for mol in expanded {
193        for atom_mol in molecule_of_atom.iter_mut().take(mol.end).skip(mol.start) {
194            *atom_mol = mol.molecule_id;
195        }
196    }
197
198    let mut minc = [F::INFINITY; 3];
199    let mut maxc = [F::NEG_INFINITY; 3];
200    for p in coordinates {
201        for k in 0..3 {
202            minc[k] = minc[k].min(p[k]);
203            maxc[k] = maxc[k].max(p[k]);
204        }
205    }
206
207    let cell = tolerance.max(1.0e-6);
208    let inv = 1.0 / cell;
209    let mut buckets: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
210    let mut max_violation = 0.0 as F;
211    let mut violating_pairs = 0usize;
212    let mut violating_atoms = vec![false; coordinates.len()];
213    let eps = precision.max(numeric_controls().epsrel);
214    let debug = env::var("MOLPACK_DEBUG_VALIDATION").is_ok();
215    let mut debug_left = 5usize;
216
217    for (i, p) in coordinates.iter().enumerate() {
218        let cx = ((p[0] - minc[0]) * inv).floor() as i64;
219        let cy = ((p[1] - minc[1]) * inv).floor() as i64;
220        let cz = ((p[2] - minc[2]) * inv).floor() as i64;
221
222        for dx in -1..=1 {
223            for dy in -1..=1 {
224                for dz in -1..=1 {
225                    let key = (cx + dx, cy + dy, cz + dz);
226                    if let Some(list) = buckets.get(&key) {
227                        for &j in list {
228                            if molecule_of_atom[i] == molecule_of_atom[j] {
229                                continue;
230                            }
231                            let q = coordinates[j];
232                            let d2 = (p[0] - q[0]).powi(2)
233                                + (p[1] - q[1]).powi(2)
234                                + (p[2] - q[2]).powi(2);
235                            let d = d2.sqrt();
236                            if d < tolerance {
237                                let v = tolerance - d;
238                                if v > max_violation {
239                                    max_violation = v;
240                                }
241                                if v > eps {
242                                    violating_pairs += 1;
243                                    violating_atoms[i] = true;
244                                    violating_atoms[j] = true;
245                                    if debug && debug_left > 0 {
246                                        eprintln!(
247                                            "validation pair: i={} j={} d={:.6} tol={:.3} v={:.6} mi={} mj={}",
248                                            i,
249                                            j,
250                                            d,
251                                            tolerance,
252                                            v,
253                                            molecule_of_atom[i],
254                                            molecule_of_atom[j]
255                                        );
256                                        debug_left -= 1;
257                                    }
258                                }
259                            }
260                        }
261                    }
262                }
263            }
264        }
265
266        buckets.entry((cx, cy, cz)).or_default().push(i);
267    }
268
269    (max_violation, violating_pairs, violating_atoms)
270}
271
272fn union_count(n: usize, a: &[bool], b: &[bool]) -> usize {
273    if a.len() != n || b.len() != n {
274        return 0;
275    }
276    (0..n).filter(|&i| a[i] || b[i]).count()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// A fixed solute declared **first** keeps its declared coordinate slot in
284    /// `PackResult::positions()`. Validation must map those leading coordinates
285    /// to the single fixed molecule — whose internal sub-tolerance bonds are
286    /// skipped — not to the first *free* molecules. A free-first molecule map
287    /// (the prior bug) slices the solute into the wrong molecules and flags its
288    /// own ~1.5 Å bonds as inter-molecule overlaps (2501 false pairs on
289    /// solvprotein). This guards the declared-order mapping in `expand_targets`.
290    #[test]
291    fn fixed_target_declared_first_skips_its_internal_contacts() {
292        // A: fixed, 4 atoms in a tight cluster (several pairs < 2.0 Å apart).
293        let a = Target::from_coords(
294            &[
295                [0.0, 0.0, 0.0],
296                [1.5, 0.0, 0.0],
297                [0.0, 1.5, 0.0],
298                [0.0, 0.0, 1.5],
299            ],
300            &[1.0; 4],
301            1,
302        )
303        .fixed_at([0.0, 0.0, 0.0]);
304        // B: free, 2 atoms/molecule, 2 copies, placed far apart.
305        let b = Target::from_coords(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], &[1.0; 2], 2);
306
307        // Coordinates in DECLARED order: A's tight cluster first, then B far away.
308        let coords = vec![
309            [0.0, 0.0, 0.0],
310            [1.5, 0.0, 0.0],
311            [0.0, 1.5, 0.0],
312            [0.0, 0.0, 1.5],
313            [50.0, 50.0, 50.0],
314            [51.0, 50.0, 50.0],
315            [60.0, 60.0, 60.0],
316            [61.0, 60.0, 60.0],
317        ];
318        let report = validate_from_targets(&[a, b], &coords, 2.0, 1e-2);
319        assert!(
320            report.is_valid(),
321            "fixed solute's internal contacts must not count as overlaps: {report:?}"
322        );
323        assert_eq!(report.metrics.violating_pairs, 0);
324    }
325}