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, ¯ostate);
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}