hoomd_mc/
quick_compress.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 `QuickCompress`
5use rand::distr::{Distribution, Uniform};
6use serde::{Deserialize, Serialize};
7
8use hoomd_geometry::{MapPoint, Scale, Volume};
9use hoomd_interaction::TotalEnergy;
10use hoomd_microstate::{
11    Body, Microstate, SiteKey, Tagged, Transform,
12    boundary::{GenerateGhosts, Wrap},
13    property::Position,
14};
15use hoomd_spatial::PointUpdate;
16use hoomd_utility::valid::{OpenUnitIntervalNumber, PositiveReal};
17
18/// Track the state of a given `QuickCompress` instance.
19#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
20enum State<C> {
21    /// `apply` has not yet been called.
22    #[default]
23    Startup,
24    /// Waiting for all overlaps to be removed.
25    ///
26    /// The first field indicates whether the compression that led to this state
27    /// achieved the target volume. The second field stores a clone of the last
28    /// boundary known to `QuickCompress`.
29    Waiting(bool, C),
30    /// Compressing the system
31    Compressing,
32    /// The system is at the target volume and all overlaps have been removed.
33    ///
34    /// The field stores a clone of the final boundary.
35    Complete(C),
36}
37
38/// Quickly compress a microstate to a target volume.
39///
40/// [`QuickCompress`] allows you to *quickly* compress a microstate to a
41/// target volume. It does so by *breaking detailed balance*, so you should
42/// use it only during the initialization phase of your simulation where
43/// you prepare a microstate for later equilibration. It works best with the
44/// [`OverlapPenalty`] potential that allows sites to overlap a small amount and
45/// for trial moves to partially reduce that overlap.
46///
47/// As a **algorithm**, [`QuickCompress`] is more than just a trial move, it
48/// stores internal state to track its progress. Therefore, you should only use
49/// a given [`QuickCompress`] on one [`Microstate`].
50///
51/// When not complete, [`apply`] takes the following steps:
52/// 1. Check the total energy of the given Hamiltonian.
53/// 2. If the total energy is less than or equal to zero *and* the microstate
54///    boundary's volume does not match [`target_volume`], compress (or
55///    expand) the boundary a small amount toward the target.  Reject any
56///    trial move that increases the per-site energy of the system more than
57///    [`maximum_energy_per_site`]. Accept in all other cases.
58///
59/// When the target volume is reached *and* the total energy is less than
60/// or equal to 0, [`QuickCompress`] transitions to the complete state.
61/// When complete, [`is_complete`] returns `true` and [`apply`] returns
62/// immediately.
63///
64/// The generic type names are:
65/// * `C`: The [`boundary`](hoomd_microstate::boundary) condition type.
66///
67/// [`apply`]: Self::apply
68/// [`maximum_energy_per_site`]: Self::maximum_energy_per_site
69/// [`target_volume`]: Self::target_volume
70/// [`is_complete`]: Self::is_complete
71/// [`OverlapPenalty`]: hoomd_interaction::univariate::OverlapPenalty
72///
73/// # Example
74///
75/// ```
76/// use hoomd_geometry::shape::Rectangle;
77/// use hoomd_mc::QuickCompress;
78/// use hoomd_microstate::boundary::Closed;
79///
80/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
81/// let quick_compress = QuickCompress::<Closed<Rectangle>>::with_target_volume(
82///     1000.0.try_into()?,
83/// );
84/// # Ok(())
85/// # }
86/// ```
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88pub struct QuickCompress<C> {
89    /// Final boundary volume after the compression algorithm completes.
90    target_volume: PositiveReal,
91
92    /// Per-site energy threshold at which to reject compression moves.
93    maximum_energy_per_site: f64,
94
95    /// Current state of the method.
96    state: State<C>,
97
98    /// Maximum value of delta.
99    maximum_delta: OpenUnitIntervalNumber,
100}
101
102impl<C> QuickCompress<C> {
103    /// Construct a new [`QuickCompress`] with the given target volume.
104    ///
105    /// The returned [`QuickCompress`] will compress (or expand) the
106    /// microstate until the boundary reaches `target_volume`.
107    ///
108    /// * [`maximum_energy_per_site`] defaults to `25.0`.
109    /// * [`maximum_delta`] defaults to `0.01`.
110    ///
111    /// These defaults are suitable for use with the default
112    /// [`OverlapPenalty`] parameters applied to typical hard-particle
113    /// systems.
114    ///
115    /// [`maximum_energy_per_site`]: Self::maximum_energy_per_site
116    /// [`maximum_delta`]: Self::maximum_delta
117    /// [`OverlapPenalty`]: hoomd_interaction::univariate::OverlapPenalty
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use hoomd_geometry::shape::Rectangle;
123    /// use hoomd_mc::QuickCompress;
124    /// use hoomd_microstate::boundary::Closed;
125    ///
126    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
127    /// let quick_compress = QuickCompress::<Closed<Rectangle>>::with_target_volume(
128    ///     1000.0.try_into()?,
129    /// );
130    /// # Ok(())
131    /// # }
132    /// ```
133    #[inline]
134    #[must_use]
135    #[expect(
136        clippy::missing_panics_doc,
137        reason = "Panic would occur due to a bug in hoomd-rs."
138    )]
139    pub fn with_target_volume(target_volume: PositiveReal) -> Self {
140        Self {
141            target_volume,
142            maximum_energy_per_site: 1000.0,
143            state: State::default(),
144            maximum_delta: 0.05
145                .try_into()
146                .expect("hard-coded constant should be in the open unit interval"),
147        }
148    }
149
150    /// Check if the compression has completed.
151    ///
152    /// `is_complete` will return `true` when the microstate has reached the
153    /// target volume *and* the total energy of the system was less than or
154    /// equal to zero during a call to [`apply`]. After that, `is_complete`
155    /// will continue to return `true` even when the system energy changes.
156    ///
157    /// [`apply`]: Self::apply
158    ///
159    /// # Example
160    ///
161    /// ```
162    /// use hoomd_geometry::shape::Rectangle;
163    /// use hoomd_mc::QuickCompress;
164    /// use hoomd_microstate::boundary::Closed;
165    ///
166    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
167    /// let quick_compress = QuickCompress::<Closed<Rectangle>>::with_target_volume(
168    ///     1000.0.try_into()?,
169    /// );
170    ///
171    /// assert!(!quick_compress.is_complete());
172    /// # Ok(())
173    /// # }
174    /// ```
175    #[inline]
176    pub fn is_complete(&self) -> bool {
177        matches!(self.state, State::Complete(_))
178    }
179
180    /// Get the target volume
181    ///
182    /// # Example
183    ///
184    /// ```
185    /// use hoomd_geometry::shape::Rectangle;
186    /// use hoomd_mc::QuickCompress;
187    /// use hoomd_microstate::boundary::Closed;
188    ///
189    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
190    /// let quick_compress = QuickCompress::<Closed<Rectangle>>::with_target_volume(
191    ///     1000.0.try_into()?,
192    /// );
193    ///
194    /// let target_volume = quick_compress.target_volume();
195    ///
196    /// assert_eq!(target_volume.get(), 1000.0);
197    /// # Ok(())
198    /// # }
199    /// ```
200    #[inline]
201    pub fn target_volume(&self) -> PositiveReal {
202        self.target_volume
203    }
204
205    /// Set the target volume.
206    ///
207    /// Setting a new value will transition a complete [`QuickCompress`] to
208    /// an active state.
209    ///
210    /// # Example
211    ///
212    /// ```
213    /// use hoomd_geometry::shape::Rectangle;
214    /// use hoomd_mc::QuickCompress;
215    /// use hoomd_microstate::boundary::Closed;
216    ///
217    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
218    /// let mut quick_compress =
219    ///     QuickCompress::<Closed<Rectangle>>::with_target_volume(
220    ///         1000.0.try_into()?,
221    ///     );
222    ///
223    /// quick_compress.set_target_volume(500.0.try_into()?);
224    /// # Ok(())
225    /// # }
226    /// ```
227    #[inline]
228    pub fn set_target_volume(&mut self, target_volume: PositiveReal)
229    where
230        C: Clone,
231    {
232        // Callers will expect `QuickCompress::compress` to seamlessly move
233        // toward the new target. How to do that depends on the current state.
234        // We cannot always switch to `Startup` because a caller *might* call
235        // `set_target_volume` every time they call `compress` and that would
236        // result in no progress.
237        if self.target_volume != target_volume {
238            self.state = match self.state {
239                State::Startup | State::Complete(_) => State::Startup,
240                State::Waiting(_, ref last_known_boundary) => {
241                    State::Waiting(false, last_known_boundary.clone())
242                }
243                State::Compressing => State::Compressing,
244            };
245        }
246
247        self.target_volume = target_volume;
248    }
249
250    /// Get the maximum energy per site.
251    ///
252    /// The largest energy per site allowed during [`apply`].
253    ///
254    /// [`apply`]: Self::apply
255    ///
256    /// # Example
257    ///
258    /// ```
259    /// use hoomd_geometry::shape::Rectangle;
260    /// use hoomd_mc::QuickCompress;
261    /// use hoomd_microstate::boundary::Closed;
262    ///
263    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
264    /// let quick_compress = QuickCompress::<Closed<Rectangle>>::with_target_volume(
265    ///     1000.0.try_into()?,
266    /// );
267    ///
268    /// let maximum_energy_per_site = quick_compress.maximum_energy_per_site();
269    /// # Ok(())
270    /// # }
271    /// ```
272    #[inline]
273    pub fn maximum_energy_per_site(&self) -> f64 {
274        self.maximum_energy_per_site
275    }
276
277    /// A mutable reference to the maximum energy per site.
278    ///
279    /// # Example
280    ///
281    /// ```
282    /// use hoomd_geometry::shape::Rectangle;
283    /// use hoomd_mc::QuickCompress;
284    /// use hoomd_microstate::boundary::Closed;
285    ///
286    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
287    /// let mut quick_compress =
288    ///     QuickCompress::<Closed<Rectangle>>::with_target_volume(
289    ///         1000.0.try_into()?,
290    ///     );
291    ///
292    /// *quick_compress.maximum_energy_per_site_mut() = 10.0;
293    /// # Ok(())
294    /// # }
295    /// ```
296    #[inline]
297    pub fn maximum_energy_per_site_mut(&mut self) -> &mut f64 {
298        &mut self.maximum_energy_per_site
299    }
300
301    /// Get the maximum value of delta.
302    ///
303    /// # Example
304    ///
305    /// ```
306    /// use hoomd_geometry::shape::Rectangle;
307    /// use hoomd_mc::QuickCompress;
308    /// use hoomd_microstate::boundary::Closed;
309    ///
310    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
311    /// let quick_compress = QuickCompress::<Closed<Rectangle>>::with_target_volume(
312    ///     1000.0.try_into()?,
313    /// );
314    ///
315    /// let maximum_delta = quick_compress.maximum_delta();
316    /// # Ok(())
317    /// # }
318    /// ```
319    #[inline]
320    pub fn maximum_delta(&self) -> OpenUnitIntervalNumber {
321        self.maximum_delta
322    }
323
324    /// A mutable reference to the maximum value of delta.
325    ///
326    /// # Example
327    ///
328    /// ```
329    /// use hoomd_geometry::shape::Rectangle;
330    /// use hoomd_mc::QuickCompress;
331    /// use hoomd_microstate::boundary::Closed;
332    ///
333    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
334    /// let mut quick_compress =
335    ///     QuickCompress::<Closed<Rectangle>>::with_target_volume(
336    ///         1000.0.try_into()?,
337    ///     );
338    ///
339    /// *quick_compress.maximum_delta_mut() = 0.001.try_into()?;
340    /// # Ok(())
341    /// # }
342    /// ```
343    #[inline]
344    pub fn maximum_delta_mut(&mut self) -> &mut OpenUnitIntervalNumber {
345        &mut self.maximum_delta
346    }
347
348    /// Apply the quick compress algorithm to a microstate.
349    ///
350    /// When the total energy of the microstate (given by `hamiltonian`) is less
351    /// than or equal to 0, `apply` scales the microstate's boundary toward
352    /// the [`target_volume`] by an amount $` 1 \pm \delta `$ where $` \delta `$
353    /// is a randomly sampled value between 0 and [`maximum_delta`].
354    ///
355    /// `should_map_body` controls how bodies in the microstate are transformed.
356    /// When `should_map_body` returns `true`, the body's position will be mapped
357    /// from the initial boundary to the final (via [`MapPoint`]). When
358    /// `should_map_body` returns `false`, the body position is wrapped
359    /// into the boundary (via [`Wrap`]).
360    ///
361    /// `apply` then evaluates the total energy change between the initial
362    /// and compressed microstates. To avoid introducing too much stress
363    /// into the system, it rejects moves where the total change in energy
364    /// per site is greater than [`maximum_energy_per_site`].
365    ///
366    /// Combine `apply` with local trial moves that translate and/or rotate
367    /// bodies by small amounts to relieve the stress caused by inserting
368    /// overlapping sites. Without local moves, the quick compress algorithm
369    /// will not achieve the target volume.
370    ///
371    /// [`target_volume`]: Self::target_volume
372    /// [`maximum_delta`]: Self::maximum_delta
373    /// [`maximum_energy_per_site`]: Self::maximum_energy_per_site
374    ///
375    /// # Example
376    ///
377    /// Hard spheres
378    /// ```
379    /// use anyhow::anyhow;
380    ///
381    /// use hoomd_geometry::{Volume, shape::Rectangle};
382    /// use hoomd_interaction::{
383    ///     PairwiseCutoff,
384    ///     pairwise::Isotropic,
385    ///     univariate::{Expanded, OverlapPenalty},
386    /// };
387    /// use hoomd_mc::{QuickCompress, Sweep, Translate, Trial};
388    /// use hoomd_microstate::{
389    ///     Body, Microstate, boundary::Periodic, property::Point,
390    /// };
391    /// use hoomd_simulation::macrostate::Isothermal;
392    /// use hoomd_vector::Cartesian;
393    ///
394    /// # fn main() -> anyhow::Result<()> {
395    /// let rectangle = Rectangle::with_equal_edges(16.0.try_into()?);
396    ///
397    /// let mut quick_compress = QuickCompress::with_target_volume(200.0.try_into()?);
398    ///
399    /// let translate = Translate::with_maximum_distance(0.1.try_into()?);
400    /// let mut translate_sweep = Sweep(translate);
401    ///
402    /// let pairwise_cutoff = PairwiseCutoff(Isotropic {
403    ///     interaction: Expanded {
404    ///         delta: 1.0,
405    ///         f: OverlapPenalty::default(),
406    ///     },
407    ///     r_cut: 1.0,
408    /// });
409    ///
410    /// let macrostate = Isothermal { temperature: 1.0 };
411    /// let mut microstate = Microstate::builder()
412    ///     .boundary(Periodic::new(1.0, rectangle)?)
413    ///     .try_build()?;
414    /// for j in 0..8 {
415    ///     let y = -8.0 + j as f64 * 2.0;
416    ///     for i in 0..8 {
417    ///         let x = -8.0 + i as f64 * 2.0;
418    ///         microstate.add_body(Body::point(Cartesian::from([x, y])))?;
419    ///     }
420    /// }
421    ///
422    /// while(!quick_compress.is_complete()) {
423    ///     quick_compress.apply(&mut microstate, &pairwise_cutoff, |_| true);
424    ///     translate_sweep.apply(&mut microstate, &pairwise_cutoff, &macrostate);
425    ///     microstate.increment_step();
426    ///
427    ///     if microstate.step() > 10_000 {
428    ///         let current = microstate.boundary().volume();
429    ///         let target = quick_compress.target_volume();
430    ///         let step = microstate.step();
431    ///         return Err(anyhow!(
432    ///             "Achieved volume {current} after {step} steps. The target was {target}."
433    ///         ));
434    ///     }
435    /// }
436    /// # Ok(())
437    /// # }
438    /// ```
439    #[expect(
440        clippy::missing_panics_doc,
441        reason = "Panic would occur due to a bug in hoomd-rs."
442    )]
443    #[inline]
444    pub fn apply<P, B, S, X, H, F>(
445        &mut self,
446        microstate: &mut Microstate<B, S, X, C>,
447        hamiltonian: &H,
448        should_map_body: F,
449    ) where
450        P: Copy,
451        B: Clone + Position<Position = P> + Transform<S>,
452        S: Clone + Position<Position = P> + Default,
453        X: Clone + PointUpdate<P, SiteKey>,
454        H: TotalEnergy<Microstate<B, S, X, C>>,
455        C: Clone + MapPoint<P> + Wrap<B> + Wrap<S> + GenerateGhosts<S> + Volume + Scale + PartialEq,
456        Microstate<B, S, X, C>: Clone,
457        F: Fn(&Tagged<Body<B, S>>) -> bool,
458    {
459        let mut rng = microstate.counter().make_rng();
460        microstate.increment_substep();
461
462        match self.state {
463            State::Startup => {
464                self.state = State::Waiting(false, microstate.boundary().clone());
465            }
466            State::Complete(ref final_boundary) if final_boundary == microstate.boundary() => {}
467            State::Complete(_) => {
468                // While it is not intended that callers change the microstate's
469                // boundary outside `QuickCompress`, it is a possibility. Should
470                // a caller do so, they would be surprised that `QuickCompress`
471                // failed to compress toward the target.
472                self.state = State::Waiting(false, microstate.boundary().clone());
473            }
474            State::Compressing => {
475                let delta_distribution = Uniform::new(0.0, self.maximum_delta.get())
476                    .expect("maximum_delta should be greater than 0.0");
477                let delta = delta_distribution.sample(&mut rng);
478                let current_volume = microstate.boundary().volume();
479
480                let trial_volume = if self.target_volume.get() > current_volume {
481                    (current_volume * (1.0 + delta)).min(self.target_volume.get())
482                } else {
483                    (current_volume * (1.0 - delta)).max(self.target_volume.get())
484                };
485
486                let trial_boundary = microstate.boundary().scale_volume(
487                    (trial_volume / current_volume)
488                        .try_into()
489                        .expect("both volumes should be positive"),
490                );
491                let Ok(trial_microstate) =
492                    microstate.clone_with_boundary(trial_boundary, should_map_body)
493                else {
494                    return;
495                };
496                let delta_energy = hamiltonian.delta_energy_total(microstate, &trial_microstate);
497
498                if delta_energy > self.maximum_energy_per_site * microstate.sites().len() as f64 {
499                    // The trial state is too strained. Remain in the `Compressing` state
500                    // to try again on the next call.
501                } else {
502                    // The trial state is valid. Transition to the waiting state and indicate
503                    // whether this trial achieved the target.
504                    self.state = State::Waiting(
505                        trial_volume == self.target_volume.get(),
506                        trial_microstate.boundary().clone(),
507                    );
508                    *microstate = trial_microstate;
509                }
510            }
511            State::Waiting(is_final, ref last_known_boundary) => {
512                if hamiltonian.total_energy(microstate) <= 0.0 {
513                    if is_final && last_known_boundary == microstate.boundary() {
514                        self.state = State::Complete(microstate.boundary().clone());
515                    } else {
516                        self.state = State::Compressing;
517                    }
518                }
519            }
520        }
521    }
522}