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, TuneOptions, 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(µstate.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, ¯ostate);
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_with_options`] and `TuneOptions:default()` unless you have a
432 /// specific need to adjust the tuning parameters.
433 ///
434 /// [`tune_with_options`]: Self::tune_with_options
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, TuneOptions};
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_with_options(
467 /// µstate,
468 /// &hamiltonian,
469 /// ¯ostate,
470 /// &TuneOptions::default(),
471 /// );
472 ///
473 /// translate_sweep.apply(&mut microstate, &hamiltonian, ¯ostate);
474 ///
475 /// # Ok(())
476 /// # }
477 /// ```
478 #[inline]
479 fn tune(
480 &mut self,
481 microstate: &Microstate<B, S, X, C>,
482 hamiltonian: &H,
483 macrostate: &MA,
484 target_acceptance: OpenUnitIntervalNumber,
485 samples: usize,
486 steps: usize,
487 ) {
488 tune_local_trial(
489 &mut self.local_trial,
490 microstate,
491 hamiltonian,
492 macrostate,
493 &TuneOptions {
494 target_acceptance,
495 samples,
496 steps,
497 },
498 |_| true,
499 );
500 }
501}