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}