Skip to main content

hoomd_interaction/pairwise/
angular_mask.rs

1// Copyright (c) 2024-2026 The Regents of the University of Michigan.
2// Part of hoomd-rs, released under the BSD 3-Clause License.
3
4//! [`AngularMask`] and related data structures.
5
6use serde::{Deserialize, Serialize};
7
8use super::AnisotropicEnergy;
9use crate::univariate::UnivariateEnergy;
10use hoomd_vector::{InnerProduct, Rotate, Unit, Vector};
11
12/// A single patch in the [`AngularMask`] potential.
13///
14/// The width of the patch is given as the cosine of its half-angle.
15///
16/// # Example
17///
18/// ```
19/// use hoomd_interaction::pairwise::angular_mask::Patch;
20/// use std::f64::consts::PI;
21///
22/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
23/// let patch = Patch {
24///     director: [0.0, 1.0, 0.0].try_into()?,
25///     cos_delta: (PI / 4.0).cos(),
26/// };
27/// # Ok(())
28/// # }
29/// ```
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct Patch<V> {
32    /// Vector pointing from the center of the particle to the center of the mask `[unitless]`.
33    pub director: Unit<V>,
34    /// Cosine of the half-angle width of the mask `[unitless]`.
35    pub cos_delta: f64,
36}
37
38/// Evaluate an isotropic pairwise energy masked by angular patches (_not differentiable_).
39///
40/// ```math
41/// U(\vec{r}_{ij}, \mathbf{o}_{ij}) = f(|\vec{r}_{ij}|) \cdot \max
42/// \left(1,
43/// \sum_{m=1}^{N_{\mathrm{masks},i}}
44/// \sum_{n=1}^{N_{\mathrm{masks},j}}
45/// s(\vec{d}_{m,i},
46/// \mathbf{o}_{ij} \vec{d}_{n,j} \mathbf{o}_{ij}^*,
47/// \delta_{m,i},
48/// \delta_{n,j}) \right)
49/// ```
50/// where
51/// ```math
52/// s(\vec{a}, \vec{b}, \delta_a, \delta_b) =
53/// \begin{cases}
54/// 1 & \hat{a} \cdot \hat{r}_{ij} \ge \cos \delta_{a} \land
55/// \hat{b} \cdot \hat{r}_{ji} \ge \cos \delta_{b} \\
56/// 0 & \text{otherwise} \\
57/// \end{cases}
58/// ```
59///
60/// Implement the [Kern-Frenkel] potential with the [`Boxcar`] isotropic potential
61/// and single patch in both `masks_i` and `masks_j`.
62///
63/// [Kern-Frenkel]: https://doi.org/10.1063/1.1569473
64/// [`Boxcar`]: crate::univariate::Boxcar
65///
66/// # Examples
67///
68/// Construction:
69///
70/// ```
71/// use hoomd_interaction::{
72///     pairwise::{AngularMask, angular_mask::Patch},
73///     univariate::Boxcar,
74/// };
75/// use hoomd_vector::Angle;
76/// use std::f64::consts::PI;
77///
78/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
79/// let boxcar = Boxcar {
80///     epsilon: -1.0,
81///     left: 1.0,
82///     right: 1.5,
83/// };
84/// let masks = [Patch {
85///     director: [1.0, 0.0].try_into()?,
86///     cos_delta: (PI / 8.0).cos(),
87/// }];
88/// let angular_mask = AngularMask::new(boxcar, masks);
89/// # Ok(())
90/// # }
91/// ```
92///
93/// All fields are public and can be directly manipulated:
94/// ```
95/// use hoomd_interaction::{
96///     pairwise::{AngularMask, angular_mask::Patch},
97///     univariate::Boxcar,
98/// };
99/// use hoomd_vector::Angle;
100/// use std::f64::consts::PI;
101///
102/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
103/// let boxcar = Boxcar {
104///     epsilon: -1.0,
105///     left: 1.0,
106///     right: 1.5,
107/// };
108/// let masks = [Patch {
109///     director: [1.0, 0.0].try_into()?,
110///     cos_delta: (PI / 8.0).cos(),
111/// }];
112/// let mut angular_mask = AngularMask::new(boxcar, masks);
113///
114/// angular_mask.masks_i[0].cos_delta = (PI / 4.0).cos();
115/// angular_mask.isotropic.epsilon = -2.0;
116/// # Ok(())
117/// # }
118/// ```
119///
120/// Evaluate energy between particles:
121///
122/// ```
123/// use hoomd_interaction::{
124///     pairwise::{AngularMask, AnisotropicEnergy, angular_mask::Patch},
125///     univariate::Boxcar,
126/// };
127/// use hoomd_vector::Angle;
128/// use std::f64::consts::PI;
129///
130/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
131/// let boxcar = Boxcar {
132///     epsilon: -1.0,
133///     left: 1.0,
134///     right: 1.5,
135/// };
136/// let masks = [Patch {
137///     director: [1.0, 0.0].try_into()?,
138///     cos_delta: (PI / 8.0).cos(),
139/// }];
140/// let angular_mask = AngularMask::new(boxcar, masks);
141///
142/// let energy = angular_mask.energy(&[1.0, 0.0].into(), &Angle::from(0.0));
143/// assert_eq!(energy, 0.0);
144///
145/// let energy = angular_mask.energy(&[1.0, 0.0].into(), &Angle::from(PI));
146/// assert_eq!(energy, -1.0);
147/// # Ok(())
148/// # }
149/// ```
150///
151/// Apply different patches to the _i_ and _j_ particles:
152/// ```
153/// use hoomd_interaction::{
154///     pairwise::{AngularMask, AnisotropicEnergy, angular_mask::Patch},
155///     univariate::Boxcar,
156/// };
157/// use hoomd_vector::Angle;
158/// use std::f64::consts::PI;
159///
160/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
161/// let boxcar = Boxcar {
162///     epsilon: -1.0,
163///     left: 1.0,
164///     right: 1.5,
165/// };
166/// let masks_i = vec![
167///     Patch {
168///         director: [1.0, 0.0].try_into()?,
169///         cos_delta: (PI / 8.0).cos(),
170///     },
171///     Patch {
172///         director: [-1.0, 0.0].try_into()?,
173///         cos_delta: (PI / 8.0).cos(),
174///     },
175/// ];
176/// let masks_j = vec![Patch {
177///     director: [0.0, 1.0].try_into()?,
178///     cos_delta: (PI / 8.0).cos(),
179/// }];
180/// let angular_mask = AngularMask {
181///     isotropic: boxcar,
182///     masks_i,
183///     masks_j,
184/// };
185///
186/// let energy = angular_mask.energy(&[-1.0, 0.0].into(), &Angle::from(0.0));
187/// assert_eq!(energy, 0.0);
188///
189/// let energy =
190///     angular_mask.energy(&[-1.0, 0.0].into(), &Angle::from(-PI / 2.0));
191/// assert_eq!(energy, -1.0);
192/// # Ok(())
193/// # }
194/// ```
195///
196/// Evaluate the angular mask potential on 3D particles:
197/// ```
198/// use hoomd_interaction::{
199///     pairwise::{AngularMask, AnisotropicEnergy, angular_mask::Patch},
200///     univariate::Boxcar,
201/// };
202/// use hoomd_vector::{Cartesian, InnerProduct, Versor};
203/// use std::f64::consts::PI;
204///
205/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
206/// let boxcar = Boxcar {
207///     epsilon: -1.0,
208///     left: 1.0,
209///     right: 1.5,
210/// };
211///
212/// let mask = [Patch {
213///     director: [0.0, 0.0, 1.0].try_into()?,
214///     cos_delta: (PI / 8.0).cos(),
215/// }];
216/// let (x_axis, _) = Cartesian::from([1.0, 0.0, 0.0]).to_unit_unchecked();
217///
218/// let angular_mask = AngularMask::new(boxcar, mask);
219///
220/// assert_eq!(
221///     angular_mask.energy(
222///         &Cartesian::from([0.0, 0.0, 1.0]),
223///         &Versor::from_axis_angle(x_axis, 0.0)
224///     ),
225///     0.0
226/// );
227/// assert_eq!(
228///     angular_mask.energy(
229///         &Cartesian::from([0.0, 0.0, 1.0]),
230///         &Versor::from_axis_angle(x_axis, PI)
231///     ),
232///     -1.0
233/// );
234/// # Ok(())
235/// # }
236/// ```
237#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
238pub struct AngularMask<E, V> {
239    /// The original potential.
240    pub isotropic: E,
241
242    /// Masks on the i particle.
243    pub masks_i: Vec<Patch<V>>,
244
245    /// Masks on the j particle.
246    pub masks_j: Vec<Patch<V>>,
247}
248
249impl<E, V> AngularMask<E, V>
250where
251    V: Vector,
252{
253    /// Construct a [`AngularMask`] with the given function and masks.
254    ///
255    /// To obtain the best performance, construct [`AngularMask`] once and
256    /// call use it many times. `new` dynamically allocates `Vec` types
257    /// and is therefore not suitable to be called per particle,
258    /// unlike other potentials such as [`LennardJones`] or [`Boxcar`].
259    ///
260    /// `new` sets both `masks_i` and `masks_j` to `masks`. Use struct initialization
261    /// syntax to set these separately.
262    ///
263    /// [`LennardJones`]: crate::univariate::LennardJones
264    /// [`Boxcar`]: crate::univariate::Boxcar
265    ///
266    /// # Example
267    ///
268    /// ```
269    /// use hoomd_interaction::{
270    ///     pairwise::{AngularMask, angular_mask::Patch},
271    ///     univariate::Boxcar,
272    /// };
273    /// use std::f64::consts::PI;
274    ///
275    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
276    /// let boxcar = Boxcar {
277    ///     epsilon: -1.0,
278    ///     left: 1.0,
279    ///     right: 1.5,
280    /// };
281    /// let masks = [Patch {
282    ///     director: [1.0, 0.0].try_into()?,
283    ///     cos_delta: (PI / 8.0).cos(),
284    /// }];
285    /// let angular_mask = AngularMask::new(boxcar, masks);
286    /// # Ok(())
287    /// # }
288    /// ```
289    #[inline]
290    #[must_use]
291    pub fn new<I>(isotropic: E, masks: I) -> Self
292    where
293        I: IntoIterator<Item = Patch<V>>,
294    {
295        let masks = Vec::from_iter(masks);
296        Self {
297            isotropic,
298            masks_i: masks.clone(),
299            masks_j: masks,
300        }
301    }
302}
303
304impl<E, V, R> AnisotropicEnergy<V, R> for AngularMask<E, V>
305where
306    E: UnivariateEnergy,
307    V: InnerProduct,
308    R: Rotate<V> + Into<R::Matrix> + Copy,
309{
310    #[inline]
311    fn energy(&self, r_ij: &V, o_ij: &R) -> f64 {
312        let o_ij_matrix: R::Matrix = (*o_ij).into();
313        let (unit_r_ij, r_ij_norm) = r_ij.to_unit_unchecked();
314        let unit_r_ji = -(*unit_r_ij.get());
315
316        for mask_j in &self.masks_j {
317            let d_j = o_ij_matrix.rotate(mask_j.director.get());
318
319            for mask_i in &self.masks_i {
320                if mask_i.director.get().dot(unit_r_ij.get()) >= mask_i.cos_delta
321                    && d_j.dot(&unit_r_ji) >= mask_j.cos_delta
322                {
323                    return self.isotropic.energy(r_ij_norm);
324                }
325            }
326        }
327
328        0.0
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use approxim::assert_relative_eq;
336    use rstest::*;
337    use std::f64::consts::PI;
338
339    use crate::univariate::{Boxcar, LennardJones};
340    use hoomd_vector::{Angle, Cartesian, InnerProduct, Versor};
341
342    #[test]
343    fn single_patch_2d() {
344        // Evaluate that patch directors, widths, and relative orientations are
345        // handled properly.
346        let epsilon = 1.125;
347        let boxcar = Boxcar {
348            epsilon,
349            left: 0.0,
350            right: 1000.0,
351        };
352
353        // First case: identical directors in the +x direction
354        let mask = [Patch {
355            director: [1.0, 0.0]
356                .try_into()
357                .expect("hard-coded vector should have non-zero length"),
358            cos_delta: (PI / 8.0).cos(),
359        }];
360        let angular_mask = AngularMask::new(boxcar.clone(), mask);
361
362        // Check corner cases when the j particle is along the patch direction.
363        assert_eq!(
364            angular_mask.energy(&Cartesian::from([1.0, 0.0]), &Angle::from(0.0)),
365            0.0
366        );
367        assert_eq!(
368            angular_mask.energy(&Cartesian::from([1.0, 0.0]), &Angle::from(PI)),
369            epsilon
370        );
371        assert_eq!(
372            angular_mask.energy(
373                &Cartesian::from([1.0, 0.0]),
374                &Angle::from(PI + PI / 8.0 - 0.001)
375            ),
376            epsilon
377        );
378        assert_eq!(
379            angular_mask.energy(
380                &Cartesian::from([1.0, 0.0]),
381                &Angle::from(PI + PI / 8.0 + 0.001)
382            ),
383            0.0
384        );
385        assert_eq!(
386            angular_mask.energy(
387                &Cartesian::from([1.0, 0.0]),
388                &Angle::from(PI + PI / 8.0 + 0.001)
389            ),
390            0.0
391        );
392
393        // When the j particle is orthogonal to the patch direction, no orientation will interact.
394        for theta in (0..100).map(|x| f64::from(x) * 2.0 * PI / 100.0) {
395            assert_eq!(
396                angular_mask.energy(&Cartesian::from([0.0, 1.0]), &Angle::from(theta)),
397                0.0
398            );
399        }
400
401        // Second case: identical directors in the 1,1 direction
402        let mask = [Patch {
403            director: [1.0, 1.0]
404                .try_into()
405                .expect("hard-coded vector should have non-zero length"),
406            cos_delta: (PI / 3.0).cos(),
407        }];
408        let angular_mask = AngularMask::new(boxcar, mask);
409
410        // Check corner cases when the j particle is along the patch direction
411        assert_eq!(
412            angular_mask.energy(&Cartesian::from([1.0, 1.0]), &Angle::from(0.0)),
413            0.0
414        );
415        assert_eq!(
416            angular_mask.energy(&Cartesian::from([1.0, 1.0]), &Angle::from(PI)),
417            epsilon
418        );
419        assert_eq!(
420            angular_mask.energy(
421                &Cartesian::from([1.0, 1.0]),
422                &Angle::from(PI + PI / 3.0 - 0.001)
423            ),
424            epsilon
425        );
426        assert_eq!(
427            angular_mask.energy(
428                &Cartesian::from([1.0, 1.0]),
429                &Angle::from(PI + PI / 3.0 + 0.001)
430            ),
431            0.0
432        );
433        assert_eq!(
434            angular_mask.energy(
435                &Cartesian::from([1.0, 1.0]),
436                &Angle::from(PI + PI / 3.0 + 0.001)
437            ),
438            0.0
439        );
440        assert_eq!(
441            angular_mask.energy(
442                &Cartesian::from([1.0, 1.0]),
443                &Angle::from(PI + PI / 3.0 + 0.001)
444            ),
445            0.0
446        );
447
448        // With the large PI / 3.0 patch, a PI / 4 offset r_ij can interact.
449        assert_eq!(
450            angular_mask.energy(&Cartesian::from([0.0, 1.0]), &Angle::from(0.0)),
451            0.0
452        );
453        assert_eq!(
454            angular_mask.energy(&Cartesian::from([0.0, 1.0]), &Angle::from(-3.0 * PI / 4.0)),
455            epsilon
456        );
457    }
458
459    #[rstest]
460    #[case([0.0, 1.0].into(), 0.0, 1.0)]
461    #[case([0.0, 1.0].into(), PI / 2.0, 0.0)]
462    #[case([0.0, 1.0].into(), PI, 1.0)]
463    #[case([0.0, -1.0].into(), 0.0, 1.0)]
464    #[case([0.0, -1.0].into(), PI / 2.0, 0.0)]
465    #[case([0.0, -1.0].into(), PI, 1.0)]
466    #[case([1.0, 0.0].into(), 0.0, 0.0)]
467    #[case([1.0, 0.0].into(), PI / 2.0, 1.0)]
468    #[case([1.0, 0.0].into(), PI, 0.0)]
469    #[case([-1.0, 0.0].into(), 0.0, 0.0)]
470    #[case([-1.0, 0.0].into(), PI / 2.0, 1.0)]
471    #[case([-1.0, 0.0].into(), PI, 0.0)]
472    fn multiple_patches_2d(#[case] r_ij: Cartesian<2>, #[case] theta: f64, #[case] expected: f64) {
473        let epsilon = 1.0;
474        let boxcar = Boxcar {
475            epsilon,
476            left: 0.0,
477            right: 1000.0,
478        };
479
480        // Third case: multiple patches and different i,j masks.
481        let masks_i = vec![
482            Patch {
483                director: [0.0, 1.0]
484                    .try_into()
485                    .expect("hard-coded vector should have non-zero length"),
486                cos_delta: (PI / 8.0).cos(),
487            },
488            Patch {
489                director: [0.0, -1.0]
490                    .try_into()
491                    .expect("hard-coded vector should have non-zero length"),
492                cos_delta: (PI / 8.0).cos(),
493            },
494            Patch {
495                director: [1.0, 0.0]
496                    .try_into()
497                    .expect("hard-coded vector should have non-zero length"),
498                cos_delta: (PI / 8.0).cos(),
499            },
500            Patch {
501                director: [-1.0, 0.0]
502                    .try_into()
503                    .expect("hard-coded vector should have non-zero length"),
504                cos_delta: (PI / 8.0).cos(),
505            },
506        ];
507        let masks_j = vec![
508            Patch {
509                director: [0.0, 1.0]
510                    .try_into()
511                    .expect("hard-coded vector should have non-zero length"),
512                cos_delta: (PI / 8.0).cos(),
513            },
514            Patch {
515                director: [0.0, -1.0]
516                    .try_into()
517                    .expect("hard-coded vector should have non-zero length"),
518                cos_delta: (PI / 8.0).cos(),
519            },
520        ];
521        let angular_mask = AngularMask {
522            isotropic: boxcar,
523            masks_i,
524            masks_j,
525        };
526
527        assert_eq!(angular_mask.energy(&r_ij, &Angle::from(theta)), expected);
528    }
529
530    #[rstest]
531    fn smooth_potential(#[values(0.9, 1.1, 1.2, 3.0)] r: f64) {
532        let epsilon = 1.0;
533        let sigma = 1.0;
534        let lj: LennardJones = LennardJones { epsilon, sigma };
535
536        let mask = [Patch {
537            director: [1.0, 0.0]
538                .try_into()
539                .expect("hard-coded vector should have non-zero length"),
540            cos_delta: (PI).cos(),
541        }];
542        let angular_mask = AngularMask::new(lj.clone(), mask);
543
544        // The patch covers the full surface. angular_mask.energy() should evaluate to the same
545        // as lj.energy() for all orientations.
546        for theta in (0..100).map(|x| f64::from(x) * 2.0 * PI / 100.0) {
547            let r_ij = Angle::from(theta).rotate(&Cartesian::from([0.0, r]));
548            assert_relative_eq!(
549                angular_mask.energy(&r_ij, &Angle::from(0.0)),
550                lj.energy(r),
551                epsilon = 1e-12
552            );
553        }
554    }
555
556    #[test]
557    fn single_patch_3d() {
558        // Evaluate that patch directors, widths, and relative orientations are
559        // handled properly in 3D.
560        let epsilon = 1.125;
561        let boxcar = Boxcar {
562            epsilon,
563            left: 0.0,
564            right: 1000.0,
565        };
566
567        // First case: identical directors in the +z direction
568        let mask = [Patch {
569            director: [0.0, 0.0, 1.0]
570                .try_into()
571                .expect("hard-coded vector should have non-zero length"),
572            cos_delta: (PI / 8.0).cos(),
573        }];
574        let angular_mask = AngularMask::new(boxcar, mask);
575
576        let (x_axis, _) = Cartesian::from([1.0, 0.0, 0.0]).to_unit_unchecked();
577        let (y_axis, _) = Cartesian::from([1.0, 0.0, 0.0]).to_unit_unchecked();
578
579        // Check corner cases when the j particle is along the patch direction.
580        assert_eq!(
581            angular_mask.energy(
582                &Cartesian::from([0.0, 0.0, 1.0]),
583                &Versor::from_axis_angle(x_axis, 0.0)
584            ),
585            0.0
586        );
587        assert_eq!(
588            angular_mask.energy(
589                &Cartesian::from([0.0, 0.0, 1.0]),
590                &Versor::from_axis_angle(y_axis, PI)
591            ),
592            epsilon
593        );
594        assert_eq!(
595            angular_mask.energy(
596                &Cartesian::from([0.0, 0.0, 1.0]),
597                &Versor::from_axis_angle(x_axis, PI + PI / 8.0 - 0.001)
598            ),
599            epsilon
600        );
601        assert_eq!(
602            angular_mask.energy(
603                &Cartesian::from([0.0, 0.0, 1.0]),
604                &Versor::from_axis_angle(y_axis, PI + PI / 8.0 + 0.001)
605            ),
606            0.0
607        );
608        assert_eq!(
609            angular_mask.energy(
610                &Cartesian::from([0.0, 0.0, 1.0]),
611                &Versor::from_axis_angle(x_axis, PI + PI / 8.0 + 0.001)
612            ),
613            0.0
614        );
615
616        // When the j particle is orthogonal to the patch direction, no orientation will interact.
617        for theta in (0..100).map(|x| f64::from(x) * 2.0 * PI / 100.0) {
618            assert_eq!(
619                angular_mask.energy(
620                    &Cartesian::from([0.0, 1.0, 0.0]),
621                    &Versor::from_axis_angle(x_axis, theta)
622                ),
623                0.0
624            );
625        }
626    }
627}