hoomd_mc/
parallel_sweep.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//! Implement `ParallelSweep`
5
6use rand::{RngExt, seq::IndexedRandom};
7use rayon::prelude::*;
8use serde::{Deserialize, Serialize};
9use std::fmt::Display;
10
11use super::{Adjust, Count, LocalTrial, Trial, Tune, tune_local::tune_local_trial};
12use hoomd_interaction::DeltaEnergyOne;
13use hoomd_microstate::{
14    Body, Microstate, SiteKey, Transform,
15    boundary::{GenerateGhosts, Wrap},
16    property::Position,
17};
18use hoomd_simulation::macrostate::Temperature;
19use hoomd_spatial::PointUpdate;
20use hoomd_utility::valid::{OpenUnitIntervalNumber, PositiveReal};
21
22use crate::{Checkerboard, Cover};
23
24/// Result of a trial move
25#[derive(Clone, Debug, Default, PartialEq)]
26enum TrialStatus {
27    /// The trial move was rejected by the Metropolis criterion.
28    #[default]
29    Rejected,
30
31    /// The trial move was accepted.
32    Accepted,
33
34    /// Either no trial move was performed (the space was empty) or the trial move left the space.
35    Invalid,
36}
37
38/// A body trial move and its status
39///
40/// `ParallelSweep` generates and evaluates many body trial moves in parallel. `BodyTrial`
41/// stores both the trial and its status.
42#[derive(Clone, Debug, Default, PartialEq)]
43struct BodyTrial<B, S> {
44    /// The trial body configuration.
45    body: Body<B, S>,
46
47    /// Index of the body in the microstate.
48    body_index: usize,
49
50    /// Status of the trial.
51    status: TrialStatus,
52}
53
54/// In parallel, apply local trial moves to bodies in the microstate.
55///
56/// The generic type names are:
57/// * `L`: The [`LocalTrial`](crate::LocalTrial) type.
58/// * `K`: The [`Checkerboard`](crate::Checkerboard) type.
59/// * `B`: The [`Body::properties`](hoomd_microstate::Body) type.
60/// * `S`: The [`Site::properties`](hoomd_microstate::Site) type.
61///
62/// # Example
63///
64/// ```
65/// use hoomd_mc::{HypercuboidCheckerboard, ParallelSweep, Translate};
66/// use hoomd_microstate::property::Point;
67/// use hoomd_vector::Cartesian;
68///
69/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
70/// let d = 0.1;
71/// let translate =
72///     Translate::<Cartesian<2>>::with_maximum_distance(d.try_into()?);
73/// let translate_sweep = ParallelSweep::<
74///     _,
75///     HypercuboidCheckerboard<2>,
76///     Point<Cartesian<2>>,
77///     Point<Cartesian<2>>,
78/// >::new(1.0.try_into()?, translate);
79/// # Ok(())
80/// # }
81/// ```
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83pub struct ParallelSweep<L, K, B, S> {
84    /// The largest distance between any two interacting bodies.
85    body_interaction_range: PositiveReal,
86
87    /// Apply this local trial to bodies in the microstate.
88    local_trial: L,
89
90    /// Cached checkerboard generated on the last call (can be updated incrementally).
91    checkerboard: K,
92
93    /// Cached storage of the body indices assigned to each space.
94    #[serde(skip)]
95    spaces: Vec<Vec<usize>>,
96
97    /// Cached storage of the body trial moves in each space.
98    #[serde(skip)]
99    body_trials: Vec<BodyTrial<B, S>>,
100}
101
102impl<L, K, B, S> ParallelSweep<L, K, B, S>
103where
104    K: Default,
105{
106    /// Construct a new `ParallelSweep`.
107    ///
108    /// To avoid applying conflicting moves at the same time, you need to
109    /// provide the largest distance between any two interacting bodies:
110    /// `body_interaction_range` (measured between the body positions).
111    /// `ParallelSweep` cannot compute this value, nor can it validate
112    /// whether the provided value is correct.
113    ///
114    /// The `local_trial` arguments determines what trial moves `ParallelSweep`
115    /// attempts.
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use hoomd_mc::{HypercuboidCheckerboard, ParallelSweep, Translate};
121    /// use hoomd_microstate::property::Point;
122    /// use hoomd_vector::Cartesian;
123    ///
124    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
125    /// let d = 0.1;
126    /// let translate =
127    ///     Translate::<Cartesian<2>>::with_maximum_distance(d.try_into()?);
128    /// let translate_sweep = ParallelSweep::<
129    ///     _,
130    ///     HypercuboidCheckerboard<2>,
131    ///     Point<Cartesian<2>>,
132    ///     Point<Cartesian<2>>,
133    /// >::new(1.0.try_into()?, translate);
134    /// # Ok(())
135    /// # }
136    /// ```
137    #[inline]
138    pub fn new(body_interaction_range: PositiveReal, local_trial: L) -> Self {
139        Self {
140            body_interaction_range,
141            local_trial,
142            checkerboard: K::default(),
143            spaces: Vec::new(),
144            body_trials: Vec::new(),
145        }
146    }
147}
148
149impl<L, K, B, S> ParallelSweep<L, K, B, S> {
150    /// Get the body interaction range.
151    #[inline]
152    pub fn body_interaction_range(&self) -> &PositiveReal {
153        &self.body_interaction_range
154    }
155
156    /// Get the body interaction range (mutable).
157    #[inline]
158    pub fn body_interaction_range_mut(&mut self) -> &mut PositiveReal {
159        &mut self.body_interaction_range
160    }
161
162    /// Get the local trial.
163    #[inline]
164    pub fn local_trial(&self) -> &L {
165        &self.local_trial
166    }
167
168    /// Get the local trial (mutable).
169    #[inline]
170    pub fn local_trial_mut(&mut self) -> &mut L {
171        &mut self.local_trial
172    }
173
174    /// Make the cached checkerboard consistent with the current microstate's boundary.
175    #[inline(always)]
176    fn update_checkerboard<P, X, C>(&mut self, microstate: &Microstate<B, S, X, C>)
177    where
178        B: Position<Position = P>,
179        K: Checkerboard<P>,
180        C: Cover<P, Checkerboard = K>,
181    {
182        let mut checkerboard_rng = microstate.counter().index(u64::MAX).make_rng();
183        microstate.boundary().cover_into(
184            &mut self.checkerboard,
185            &mut checkerboard_rng,
186            self.body_interaction_range,
187        );
188
189        self.spaces
190            .resize_with(self.checkerboard.num_spaces(), Vec::default);
191        for space in &mut self.spaces {
192            space.truncate(0);
193        }
194        for (body_index, body) in microstate.bodies().iter().enumerate() {
195            let space_index = self
196                .checkerboard
197                .point_to_space_index(body.item.properties.position());
198            self.spaces[space_index.expect("body should be inside the checkerboard")]
199                .push(body_index);
200        }
201    }
202
203    /// Generate trial moves in parallel.
204    #[expect(
205        clippy::too_many_arguments,
206        reason = "many arguments must be passed to avoid too many lines of code in other methods"
207    )]
208    #[inline(always)]
209    fn generate_trial_moves<P, X, C, H>(
210        local_trial: &L,
211        body_trials: &mut Vec<BodyTrial<B, S>>,
212        microstate: &Microstate<B, S, X, C>,
213        hamiltonian: &H,
214        kt: f64,
215        checkerboard: &K,
216        spaces: &[Vec<usize>],
217        space_indices: &Vec<usize>,
218    ) where
219        P: Copy,
220        B: Copy + Default + Transform<S> + Position<Position = P> + Send + Sync,
221        S: Copy + Default + Position<Position = P> + Send + Sync,
222        L: LocalTrial<B> + Sync,
223        H: DeltaEnergyOne<B, S, X, C> + Sync,
224        C: Wrap<B> + Wrap<S> + GenerateGhosts<S> + Sync,
225        K: Checkerboard<P> + Sync,
226        X: Sync,
227    {
228        body_trials.resize_with(space_indices.len(), Default::default);
229
230        body_trials
231            .par_iter_mut()
232            .zip(space_indices)
233            .for_each(|(body_trial, space_index)| {
234                // body_trials.iter_mut().zip(space_indices).for_each(|(body_trial, space_index)| {
235                body_trial.status = TrialStatus::Invalid;
236                let space_index = *space_index;
237                let mut rng = microstate.counter().index(space_index as u64).make_rng();
238
239                if let Some(body_index) = spaces[space_index].choose(&mut rng) {
240                    let body_index = *body_index;
241                    body_trial
242                        .body
243                        .clone_from(&microstate.bodies()[body_index].item);
244                    body_trial.body_index = body_index;
245
246                    match microstate
247                        .boundary()
248                        .wrap(local_trial.propose(&mut rng, body_trial.body.properties))
249                    {
250                        Ok(new_properties)
251                            if checkerboard.point_to_space_index(new_properties.position())
252                                != Some(space_index) =>
253                        {
254                            body_trial.status = TrialStatus::Invalid;
255                        }
256                        Ok(new_properties) => {
257                            body_trial.body.properties = new_properties;
258
259                            let delta_h = hamiltonian.delta_energy_one(
260                                microstate,
261                                body_index,
262                                &body_trial.body,
263                            );
264                            if delta_h != f64::INFINITY
265                                && (delta_h <= 0.0 || rng.random::<f64>() < (-delta_h / kt).exp())
266                            {
267                                body_trial.status = TrialStatus::Accepted;
268                            } else {
269                                body_trial.status = TrialStatus::Rejected;
270                            }
271                        }
272                        Err(_) => body_trial.status = TrialStatus::Rejected,
273                    }
274                }
275            });
276    }
277
278    /// Update the properties of bodies with accepted moves.
279    #[inline(always)]
280    fn update_bodies<P, X, C>(
281        microstate: &mut Microstate<B, S, X, C>,
282        body_trials: &Vec<BodyTrial<B, S>>,
283    ) -> Count
284    where
285        P: Copy,
286        B: Copy + Default + Transform<S> + Position<Position = P>,
287        S: Copy + Default + Position<Position = P>,
288        X: PointUpdate<P, SiteKey>,
289        C: Wrap<B> + Wrap<S> + GenerateGhosts<S>,
290    {
291        let mut count = Count::default();
292
293        for body_trial in body_trials {
294            match body_trial.status {
295                TrialStatus::Rejected => count.rejected += 1,
296                TrialStatus::Invalid => {}
297                TrialStatus::Accepted => {
298                    if microstate
299                        .update_body_properties(body_trial.body_index, body_trial.body.properties)
300                        .is_ok()
301                    {
302                        count.accepted += 1;
303                    } else {
304                        count.rejected += 1;
305                    }
306                }
307            }
308        }
309        count
310    }
311}
312
313impl<P, B, S, X, C, L, H, MA, K> Trial<Microstate<B, S, X, C>, H, MA> for ParallelSweep<L, K, B, S>
314where
315    P: Copy,
316    B: Copy + Default + Transform<S> + Position<Position = P> + Send + Sync,
317    S: Copy + Default + Position<Position = P> + Send + Sync,
318    X: PointUpdate<P, SiteKey> + Sync,
319    L: LocalTrial<B> + Sync,
320    H: DeltaEnergyOne<B, S, X, C> + Sync,
321    C: Wrap<B> + Wrap<S> + GenerateGhosts<S> + Cover<P, Checkerboard = K> + Sync,
322    MA: Temperature,
323    K: Checkerboard<P> + Sync,
324{
325    type Count = Count;
326
327    /// In parallel, apply local trial moves to bodies in the microstate.
328    ///
329    /// Each trial move is accepted when:
330    /// ```math
331    /// r < \exp\left(\frac{-\Delta H}{kT}\right)
332    /// ```
333    /// where `r` is a random value uniformly distributed in `[0,1)`, $`\Delta H`$ is
334    /// the change in energy computed by the given `hamiltonian` and $`kT`$ is the
335    /// `temperature` given in `macrostate`.
336    ///
337    /// `ParallelSweep` covers the simulation domain with a colored checkerboard
338    /// (given by the `K` type). For each color, `apply` applies trial moves
339    /// in parallel to bodies checkerboard spaces of that color (one trial move
340    /// per space). A move is invalid when the body center leaves the initial
341    /// checkerboard space. The return value counts only valid moves.
342    ///
343    /// One invocation of `apply` continues to apply trial moves until it
344    /// has attempted at least as many trial moves as there are bodies in
345    /// the microstate. Therefore, one call to `ParallelSweep::apply` roughly
346    /// equivalent to one call to [`Sweep::apply`]. However, `ParallelSweep`
347    /// will *always* attempt more trial moves because it rounds up. To make
348    /// quantitative comparisons between the methods, normalize by the number of
349    /// attempted moves.
350    ///
351    /// [`Sweep::apply`]: crate::Sweep::apply
352    ///
353    /// # Example
354    ///
355    /// ```
356    /// use hoomd_geometry::shape::Rectangle;
357    /// use hoomd_interaction::Zero;
358    /// use hoomd_mc::{ParallelSweep, Translate, Trial};
359    /// use hoomd_microstate::{
360    ///     Body, Microstate, boundary::Closed, property::Position,
361    /// };
362    /// use hoomd_simulation::macrostate::Isothermal;
363    /// use hoomd_vector::Cartesian;
364    ///
365    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
366    /// let square = Rectangle::with_equal_edges(10.0.try_into()?);
367    /// let mut microstate = Microstate::builder()
368    ///     .boundary(Closed(square))
369    ///     .bodies([Body::point(Cartesian::from([0.0, 0.0]))])
370    ///     .try_build()?;
371    /// let d = 0.1;
372    /// let translate = Translate::with_maximum_distance(d.try_into()?);
373    /// let mut translate_sweep = ParallelSweep::new(1.0.try_into()?, translate);
374    ///
375    /// let hamiltonian = Zero;
376    /// let macrostate = Isothermal { temperature: 1.0 };
377    ///
378    /// translate_sweep.apply(&mut microstate, &hamiltonian, &macrostate);
379    /// microstate.increment_step();
380    /// # Ok(())
381    /// # }
382    /// ```
383    #[inline]
384    fn apply(
385        &mut self,
386        microstate: &mut Microstate<B, S, X, C>,
387        hamiltonian: &H,
388        macrostate: &MA,
389    ) -> Self::Count {
390        let kt = macrostate.temperature();
391
392        self.update_checkerboard(microstate);
393
394        let mut count = Self::Count::default();
395
396        while count.total() < microstate.bodies().len() as u64 {
397            for space_indices in self.checkerboard.space_indices_by_color() {
398                Self::generate_trial_moves(
399                    &self.local_trial,
400                    &mut self.body_trials,
401                    microstate,
402                    hamiltonian,
403                    *kt,
404                    &self.checkerboard,
405                    &self.spaces,
406                    space_indices,
407                );
408
409                count += Self::update_bodies(microstate, &self.body_trials);
410            }
411            microstate.increment_substep();
412        }
413
414        count
415    }
416}
417
418impl<P, B, S, X, C, L, H, MA, K> Tune<P, B, S, X, C, L, H, MA> for ParallelSweep<L, K, B, S>
419where
420    P: Copy,
421    B: Copy + Default + Transform<S> + Position<Position = P>,
422    S: Copy + Default + Position<Position = P>,
423    X: PointUpdate<P, SiteKey>,
424    L: LocalTrial<B> + Adjust + Display,
425    H: DeltaEnergyOne<B, S, X, C>,
426    C: Wrap<B> + Wrap<S> + GenerateGhosts<S>,
427    MA: Temperature,
428{
429    /// Tune the trial move maximum size to achieve a given acceptance ratio.
430    ///
431    /// Use [`tune_default`] unless you have a specific need to adjust the
432    /// tuning parameters.
433    ///
434    /// [`tune_default`]: Self::tune_default
435    ///
436    /// # Example
437    ///
438    /// ```
439    /// use hoomd_geometry::shape::Rectangle;
440    /// use hoomd_interaction::{
441    ///     MaximumInteractionRange, PairwiseCutoff, pairwise::HardSphere,
442    /// };
443    /// use hoomd_mc::{ParallelSweep, Translate, Trial, Tune};
444    /// use hoomd_microstate::{
445    ///     Body, Microstate, boundary::Periodic, property::Position,
446    /// };
447    /// use hoomd_simulation::macrostate::Isothermal;
448    /// use hoomd_vector::Cartesian;
449    ///
450    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
451    /// let square = Rectangle::with_equal_edges(10.0.try_into()?);
452    /// let mut microstate = Microstate::builder()
453    ///     .boundary(Periodic::new(1.0, square)?)
454    ///     .try_build()?;
455    /// microstate.add_body(Body::point(Cartesian::from([-0.6, -0.6])))?;
456    /// microstate.add_body(Body::point(Cartesian::from([-0.6, 0.6])))?;
457    /// microstate.add_body(Body::point(Cartesian::from([0.6, -0.6])))?;
458    /// microstate.add_body(Body::point(Cartesian::from([0.6, 0.6])))?;
459    /// let d = 0.1;
460    /// let translate = Translate::with_maximum_distance(d.try_into()?);
461    /// let mut translate_sweep = ParallelSweep::new(1.0.try_into()?, translate);
462    ///
463    /// let hamiltonian = PairwiseCutoff(HardSphere { diameter: 1.0 });
464    /// let macrostate = Isothermal { temperature: 1.0 };
465    ///
466    /// translate_sweep.tune_default(&microstate, &hamiltonian, &macrostate);
467    ///
468    /// translate_sweep.apply(&mut microstate, &hamiltonian, &macrostate);
469    ///
470    /// # Ok(())
471    /// # }
472    /// ```
473    #[inline]
474    fn tune(
475        &mut self,
476        microstate: &Microstate<B, S, X, C>,
477        hamiltonian: &H,
478        macrostate: &MA,
479        target_acceptance: OpenUnitIntervalNumber,
480        samples: usize,
481        steps: usize,
482    ) {
483        tune_local_trial(
484            &mut self.local_trial,
485            microstate,
486            hamiltonian,
487            macrostate,
488            target_acceptance,
489            samples,
490            steps,
491        );
492    }
493}