Skip to main content

hoomd_md/method/
constant_volume.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 `ConstantVolume`.
5
6use serde::{Deserialize, Serialize};
7use std::array;
8
9use crate::{
10    RotationalKineticEnergy, RotationalMotion, Thermostat, TranslationalKineticEnergy,
11    TranslationalMotion, thermostat::NoThermostat,
12};
13use hoomd_microstate::{
14    Body, Microstate, SiteKey, Tagged, Transform,
15    boundary::{GenerateGhosts, Wrap},
16    property::{
17        AngularMomentum, DynamicOrientedPoint, Mass, MomentOfInertia, Momentum, NetForce,
18        NetTorque, Orientation, Position,
19    },
20};
21use hoomd_spatial::PointUpdate;
22use hoomd_vector::{Angle, Cartesian, InnerProduct, Quaternion, Rotate, Rotation, Versor};
23
24/// Integrate bodies' translational and rotational degrees of freedom in the microstate.
25///
26/// The `ConstantVolume` implementation follows the symplectic integration
27/// scheme by [Tuckerman et al. 2006] for translational motion and [Miller et
28/// al. 2002] for rotational motion.
29///
30/// Use [`NoThermostat`] to integrate trajectories that sample the microcanonical ensemble:
31/// ```
32/// use hoomd_md::method::ConstantVolume;
33///
34/// let delta_t = 0.001;
35/// let constant_volume = ConstantVolume::builder(delta_t).build();
36/// ```
37///
38/// Use [`Bussi`] (or one of the other thermostats) to integrate trajectories that sample
39/// the canonical ensemble:
40/// ```
41/// use hoomd_md::{method::ConstantVolume, thermostat::Bussi};
42///
43/// let delta_t = 0.001;
44/// let constant_volume = ConstantVolume::builder(delta_t)
45///     .thermostat(Bussi::default())
46///     .build();
47/// ```
48///
49/// # Reference
50///
51/// * [Tuckerman et al. 2006]
52/// * [Miller et al. 2002]
53///
54/// [`NoThermostat`]: crate::thermostat::NoThermostat
55/// [`Bussi`]: crate::thermostat::Bussi
56/// [Tuckerman et al. 2006]: https://doi.org/10.1088/0305-4470/39/19/S18
57/// [Miller et al. 2002]: https://doi.org/10.1063/1.1473654
58#[doc(alias = "nvt")]
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60pub struct ConstantVolume<TT, TR = TT> {
61    /// The time step size.
62    pub delta_t: f64,
63
64    /// Translational thermostat.
65    pub translational_thermostat: TT,
66
67    /// Rotational thermostat.
68    pub rotational_thermostat: TR,
69}
70
71/// Builder that constructs [`ConstantVolume`].
72///
73/// Call [`ConstantVolume::builder`] to start building a new [`ConstantVolume`].
74pub struct ConstantVolumeBuilder<TT, TR> {
75    /// The time step size.
76    delta_t: f64,
77
78    /// Translational thermostat.
79    translational_thermostat: TT,
80
81    /// Rotational thermostat.
82    rotational_thermostat: TR,
83}
84
85impl<TT, TR> ConstantVolumeBuilder<TT, TR> {
86    /// Set the thermostat that applies to the translational degrees of freedom.
87    ///
88    /// # Example
89    ///
90    /// ```
91    /// use hoomd_md::{method::ConstantVolume, thermostat::Bussi};
92    ///
93    /// let delta_t = 0.001;
94    /// let constant_volume = ConstantVolume::builder(delta_t)
95    ///     .translational_thermostat(Bussi::default())
96    ///     .build();
97    /// ```
98    #[inline]
99    pub fn translational_thermostat<T>(
100        self,
101        translational_thermostat: T,
102    ) -> ConstantVolumeBuilder<T, TR> {
103        ConstantVolumeBuilder {
104            delta_t: self.delta_t,
105            translational_thermostat,
106            rotational_thermostat: self.rotational_thermostat,
107        }
108    }
109
110    /// Set the thermostat that applies to the rotational degrees of freedom.
111    ///
112    /// # Example
113    ///
114    /// ```
115    /// use hoomd_md::{method::ConstantVolume, thermostat::Bussi};
116    ///
117    /// let delta_t = 0.001;
118    /// let constant_volume = ConstantVolume::builder(delta_t)
119    ///     .rotational_thermostat(Bussi::default())
120    ///     .build();
121    /// ```
122    #[inline]
123    pub fn rotational_thermostat<T>(
124        self,
125        rotational_thermostat: T,
126    ) -> ConstantVolumeBuilder<TT, T> {
127        ConstantVolumeBuilder {
128            delta_t: self.delta_t,
129            translational_thermostat: self.translational_thermostat,
130            rotational_thermostat,
131        }
132    }
133
134    /// Set the thermostat that applies to both translational and rotational degrees of freedom.
135    ///
136    /// The given thermostat is cloned. The translational and rotational thermostats evolve
137    /// independently.
138    ///
139    /// # Example
140    ///
141    /// ```
142    /// use hoomd_md::{method::ConstantVolume, thermostat::Bussi};
143    ///
144    /// let delta_t = 0.001;
145    /// let constant_volume = ConstantVolume::builder(delta_t)
146    ///     .thermostat(Bussi::default())
147    ///     .build();
148    /// ```
149    #[inline]
150    pub fn thermostat<T: Clone>(self, thermostat: T) -> ConstantVolumeBuilder<T, T> {
151        ConstantVolumeBuilder {
152            delta_t: self.delta_t,
153            translational_thermostat: thermostat.clone(),
154            rotational_thermostat: thermostat,
155        }
156    }
157
158    /// Complete building a new [`ConstantVolume`].
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// use hoomd_md::method::ConstantVolume;
164    ///
165    /// let delta_t = 0.001;
166    /// let constant_volume = ConstantVolume::builder(delta_t).build();
167    /// ```
168    #[inline]
169    pub fn build(self) -> ConstantVolume<TT, TR> {
170        ConstantVolume {
171            delta_t: self.delta_t,
172            translational_thermostat: self.translational_thermostat,
173            rotational_thermostat: self.rotational_thermostat,
174        }
175    }
176}
177
178impl ConstantVolume<NoThermostat, NoThermostat> {
179    #[inline]
180    /// Start building a new `ConstantVolume`.
181    ///
182    /// The default builder uses the given value for `delta_t` and [`NoThermostat`]
183    /// for both the translational and rotational thermostats. Call zero or more
184    /// of the [`ConstantVolumeBuilder`] methods to set the thermostats.
185    ///
186    /// # Example
187    ///
188    /// ```
189    /// use hoomd_md::method::ConstantVolume;
190    ///
191    /// let delta_t = 0.001;
192    /// let constant_volume = ConstantVolume::builder(delta_t).build();
193    /// ```
194    /// [`NoThermostat`]: crate::thermostat::NoThermostat
195    pub fn builder(delta_t: f64) -> ConstantVolumeBuilder<NoThermostat, NoThermostat> {
196        ConstantVolumeBuilder {
197            delta_t,
198            translational_thermostat: NoThermostat,
199            rotational_thermostat: NoThermostat,
200        }
201    }
202}
203
204impl<TT, TR> ConstantVolume<TT, TR> {
205    /// Access the translational thermostat.
206    #[inline]
207    pub fn translational_thermostat(&self) -> &TT {
208        &self.translational_thermostat
209    }
210
211    /// Access the translational thermostat (mutable).
212    #[inline]
213    pub fn translational_thermostat_mut(&mut self) -> &mut TT {
214        &mut self.translational_thermostat
215    }
216
217    /// Access the rotational thermostat.
218    #[inline]
219    pub fn rotational_thermostat(&self) -> &TR {
220        &self.rotational_thermostat
221    }
222
223    /// Access the rotational thermostat (mutable).
224    #[inline]
225    pub fn rotational_thermostat_mut(&mut self) -> &mut TR {
226        &mut self.rotational_thermostat
227    }
228}
229
230impl<V, B, S, X, C, TT, TR, M> TranslationalMotion<B, S, X, C, M> for ConstantVolume<TT, TR>
231where
232    V: Default + InnerProduct,
233    B: Position<Position = V>
234        + Momentum<Momentum = V>
235        + NetForce<NetForce = V>
236        + Mass
237        + Transform<S>
238        + Clone,
239    S: Position<Position = V> + Default,
240    X: PointUpdate<V, SiteKey>,
241    C: Wrap<B> + Wrap<S> + GenerateGhosts<S>,
242    TT: Thermostat<M>,
243{
244    /// Integrate selected body positions forward a full step and their momenta forward a half step.
245    ///
246    /// The first half step of the symplectic integration procedure is given by the equations below, which are
247    /// applied to each selected body *i*. In each step, the marker $`'`$ is used when a variable's value changes
248    /// during a step to distinguish the value before ( $`'`$ is present) from the value after ( $`'`$ is absent).
249    ///
250    /// 1. The translational thermostat is integrated forward a half-step and then momentum is rescaled accordingly:
251    ///
252    ///    ```math
253    ///    \vec{p}_i\left( t \right) = \vec{p'}_i\left( t \right) \cdot \mathrm{translational\_thermostat.integrate\_half\_step\_one}\left( \sum_{j \in \mathrm{selection}} K'_{trans,j} \left( t \right) \right)
254    ///    ```
255    ///    where the summation represents the total [translational kinetic energy](crate::compute::TranslationalKineticEnergy)
256    ///    of the selected bodies at the start of the step, and `translational_thermostat.integrate_half_step_one()` is the
257    ///    first half step method implemented by `TT`.
258    ///
259    /// 2. Momentum is integrated forward a half step.
260    ///
261    ///    ```math
262    ///    \vec{p}_i\left( t + \frac{\Delta t}{2} \right) = \vec{p}_i\left( t \right) + \vec{F}_i(t) \frac{\Delta t}{2}
263    ///    ```
264    ///
265    /// 3. Position is integrated forward a full step using the new momentum.
266    ///
267    ///    ```math
268    ///    \vec{r}_i\left( t + \Delta t \right) = \vec{r}_i\left( t \right) + \frac{\vec{p}_i\left( t + \frac{\Delta t}{2} \right)}{m_i} \Delta t
269    ///    ```
270    #[inline]
271    fn integrate_translation_half_step_one_with_filter<F: Fn(&Tagged<Body<B, S>>) -> bool>(
272        &mut self,
273        microstate: &mut Microstate<B, S, X, C>,
274        macrostate: &M,
275        should_integrate_body: F,
276    ) {
277        let mut rng = microstate.counter().make_rng();
278        let (kinetic_energy, degrees_of_freedom) =
279            microstate.translational_kinetic_energy_with_filter(&should_integrate_body);
280
281        let conserved_degrees_of_freedom =
282            if degrees_of_freedom == V::n_dimensions() * microstate.bodies().len() {
283                V::n_dimensions()
284            } else {
285                0
286            };
287        *microstate.conserved_degrees_of_freedom_mut() = conserved_degrees_of_freedom;
288
289        let rescaling_factor = self.translational_thermostat.integrate_half_step_one(
290            &mut rng,
291            macrostate,
292            self.delta_t,
293            kinetic_energy,
294            degrees_of_freedom - conserved_degrees_of_freedom,
295        );
296
297        for body_index in 0..microstate.bodies().len() {
298            let body = &microstate.bodies()[body_index];
299            if !should_integrate_body(body) {
300                continue;
301            }
302            let mut body_properties = body.item.properties.clone();
303
304            let net_force = *body_properties.net_force();
305            let mass = body_properties.mass();
306            let mut momentum = *body_properties.momentum();
307
308            momentum *= rescaling_factor;
309            momentum += net_force * 0.5 * self.delta_t;
310            *body_properties.position_mut() += momentum / mass * self.delta_t;
311            *body_properties.momentum_mut() = momentum;
312
313            microstate
314                .update_body_properties(body_index, body_properties)
315                .expect(
316                    "Bodies and sites should remain in simulation boundary.\n
317                Add interactions that prevent sites from moving outside the boundary.",
318                );
319        }
320
321        microstate.increment_substep();
322    }
323
324    /// Integrate selected body momenta forward a half step.
325    ///
326    /// The second half step of the symplectic integration procedure is given by the equations below, which are
327    /// applied to each selected body *i*. In each step, the marker $`'`$ is used when a variable's value changes
328    /// during a step to distinguish the value before ( $`'`$ is present) from the value after ( $`'`$ is absent).
329    ///
330    /// 1. Momentum is integrated forward a half step.
331    ///
332    ///    ```math
333    ///    \vec{p}_i\left( t + \Delta t \right) = \vec{p}_i\left( t + \frac{\Delta t}{2} \right) + \vec{F}_i\left( t + \frac{\Delta t}{2} \right) \frac{\Delta t}{2}
334    ///    ```
335    ///
336    /// 2. The translational thermostat is integrated forward a half step and then momentum is rescaled accordingly.
337    ///
338    ///    ```math
339    ///    \vec{p}_i\left( t + \Delta t \right) = \vec{p'}_i\left( t + \Delta t \right) \cdot \mathrm{translational\_thermostat.integrate\_half\_step\_two}\left( \sum_{j \in \mathrm{selection}} K'_{trans,j} \left( t + \Delta t \right) \right)
340    ///    ```
341    ///
342    ///    where the summation represents the total [translational kinetic energy](crate::compute::TranslationalKineticEnergy)
343    ///    of the selected bodies at the start of the step, and `translational_thermostat.integrate_half_step_two()` is the
344    ///    second half step method implemented by `TT`.
345    #[inline]
346    fn integrate_translation_half_step_two_with_filter<F: Fn(&Tagged<Body<B, S>>) -> bool>(
347        &mut self,
348        microstate: &mut Microstate<B, S, X, C>,
349        macrostate: &M,
350        should_integrate_body: F,
351    ) {
352        let mut rng = microstate.counter().make_rng();
353
354        for body_index in 0..microstate.bodies().len() {
355            let body = &microstate.bodies()[body_index];
356            if !should_integrate_body(body) {
357                continue;
358            }
359            let mut body_properties = body.item.properties.clone();
360            let net_force = *body_properties.net_force();
361
362            *body_properties.momentum_mut() += net_force * self.delta_t * 0.5;
363
364            microstate
365                .update_body_properties(body_index, body_properties)
366                .expect(
367                    "Bodies and sites should remain in simulation boundary.\n
368                Add interactions that prevent sites from moving outside the boundary.",
369                );
370        }
371
372        let (kinetic_energy, degrees_of_freedom) = microstate.translational_kinetic_energy();
373        let rescaling_factor = self.translational_thermostat.integrate_half_step_two(
374            &mut rng,
375            macrostate,
376            self.delta_t,
377            kinetic_energy,
378            degrees_of_freedom - microstate.conserved_degrees_of_freedom(),
379        );
380
381        if rescaling_factor != 1.0 {
382            for body_index in 0..microstate.bodies().len() {
383                let body = &microstate.bodies()[body_index];
384                if !should_integrate_body(body) {
385                    continue;
386                }
387                let mut body_properties = body.item.properties.clone();
388
389                *body_properties.momentum_mut() *= rescaling_factor;
390
391                microstate
392                    .update_body_properties(body_index, body_properties)
393                    .expect(
394                        "Bodies and sites should remain in simulation boundary.\n
395                    Add interactions that prevent sites from moving outside the boundary.",
396                    );
397            }
398        }
399
400        microstate.increment_substep();
401    }
402}
403
404/// Compute the net torque in the body frame.
405///
406/// Also determine which of the three rotational degrees of freedom are active.
407fn body_net_torque_and_active_degrees_of_freedom(
408    body_properties: &DynamicOrientedPoint<Cartesian<3>, Versor>,
409) -> (Cartesian<3>, [bool; 3]) {
410    let q = body_properties.orientation();
411    let moment_of_inertia = body_properties.moment_of_inertia();
412
413    let mut net_torque = q.inverted().rotate(body_properties.net_torque());
414
415    let active = array::from_fn(|i| moment_of_inertia[i] != 0.0);
416
417    // Limited numerical precision might lead to non-zero torques about axes that should
418    // not be integrated. Zeroing these out improves the stability of the integration.
419    for i in 0..3 {
420        if !active[i] {
421            net_torque[i] = 0.0;
422        }
423    }
424
425    (net_torque, active)
426}
427
428/// Rotational motion in 3-dimensional cartesian space.
429impl<S, X, C, TT, TR, M> RotationalMotion<DynamicOrientedPoint<Cartesian<3>, Versor>, S, X, C, M>
430    for ConstantVolume<TT, TR>
431where
432    DynamicOrientedPoint<Cartesian<3>, Versor>: Transform<S>,
433    S: Position<Position = Cartesian<3>> + Default,
434    X: PointUpdate<Cartesian<3>, SiteKey>,
435    C: Wrap<DynamicOrientedPoint<Cartesian<3>, Versor>> + Wrap<S> + GenerateGhosts<S>,
436    TR: Thermostat<M>,
437{
438    /// Integrate selected body orientations forward a full step and their angular momenta forward a half step.
439    ///
440    /// The first half step of the symplectic integration procedure is given by the equations below, which are
441    /// applied to each selected body *i*. In each step, the marker $`'`$ is used when a variable's value changes
442    /// during a step to distinguish the value before ( $`'`$ is present) from the value after ( $`'`$ is absent).
443    /// Rotational degrees of freedom with a moment of inertia component of zero are skipped.
444    ///
445    /// 1. The rotational thermostat is integrated forward a half-step and then angular momentum is rescaled
446    ///    accordingly:
447    ///
448    ///    ```math
449    ///    \vec{L}_i(t) = \vec{L}'_i(t) \cdot \mathrm{rotational\_thermostat.integrate\_half\_step\_one}\left(\sum_{j \in \mathrm{selection}} K'_{rot,j}(t) \right)
450    ///    ```
451    ///
452    ///    where the summation represents the total [rotational kinetic energy](crate::compute::RotationalKineticEnergy)
453    ///    of the selected bodies at the start of the step, and `rotational_thermostat.integrate_half_step_one()` is the
454    ///    first half step method implemented by `TR`.
455    ///
456    /// 2. Angular momentum $`\vec{L}`$ and orientation $`\mathbf{q}`$ are integrated forward. These integrations
457    ///    follow a complex, multi-step process, so a fuller explanation is provided below. In each step, the body
458    ///    index *i* and time *t* are implicit on every variable unless otherwise specified.
459    ///
460    ///    1. Angular momentum and net torque are converted to quaternions $`\mathbf{p}`$ and
461    ///       $`\mathbf{f}`$, respectively:
462    ///
463    ///       ```math
464    ///       \begin{align*}
465    ///
466    ///       \mathbf{p} &= 2\mathbf{S}(\mathbf{q}) \mathbf{L} \\
467    ///       \mathbf{f} &= 2\mathbf{S}(\mathbf{q}) \boldsymbol{\tau} \\
468    ///
469    ///       \end{align*}
470    ///       ```
471    ///
472    ///       where
473    ///
474    ///       ```math
475    ///       \begin{align*}
476    ///
477    ///       \mathbf{L} &= (0, L_x, L_y, L_z) \\
478    ///       \boldsymbol{\tau} &= (0, \tau_x, \tau_y, \tau_z) \\
479    ///
480    ///       \mathbf{S}(\mathbf{q}) &=
481    ///       \begin{pmatrix}
482    ///       q_0 & -q_1 & -q_2 & -q_3\\
483    ///       q_1 & q_0 & -q_3 & q_2\\
484    ///       q_2 & q_3 & q_0 & -q_1\\
485    ///       q_3 & -q_2 & q_1 & q_0
486    ///       \end{pmatrix}
487    ///
488    ///       \end{align*}
489    ///       ```
490    ///
491    ///     2. $`\mathbf{p}`$ and $`\mathbf{q}`$ are integrated forward using the novel symplectic
492    ///        quaternion scheme (`NO_SQUISH`) algorithm, which ensures the integration is both symplectic
493    ///        and preserves orientation quaternion unity. There are several steps to this algorithm, whose
494    ///        equations are given below.
495    ///
496    ///        1. $`\mathbf{p}`$ is partially integrated forward a half step.
497    ///
498    ///            ```math
499    ///            \mathbf{p} = \mathbf{p}' + \frac{\Delta t}{2} \mathbf{f}
500    ///            ```
501    ///
502    ///        2. $`\mathbf{p}`$ is integrated forward the remainder of the half step and $`\mathbf{q}`$ is integrated
503    ///           forward a full step. Properties of quaternion algebra are used to decompose the Liouvillian into a
504    ///           sum over permutation matrices applied to $`\mathbf{q}`$ and $`\mathbf{p}`$. There are five steps
505    ///           to this decomposition:
506    ///
507    ///           ```math
508    ///           \begin{align*}
509    ///
510    ///           \phi_3 &= \frac{1}{4 I_{33}} \mathrm{dot} \left( \mathbf{p}, P_3 \mathbf{q} \right) \\
511    ///           \mathbf{q} &= \cos{(\phi_3 \frac{\Delta t}{2})} \mathbf{q}^{'} +  \sin{(\phi_3 \frac{\Delta t}{2})} P_3 \mathbf{q}^{'} \nonumber \\
512    ///           \mathbf{p} &= \cos{(\phi_3 \frac{\Delta t}{2})} \mathbf{p}' +  \sin{(\phi_3 \frac{\Delta t}{2})} P_3 \mathbf{p}' \nonumber \\ \nonumber \\
513    ///
514    ///           \phi_2 &= \frac{1}{4 I_{22}} \mathrm{dot} \left( \mathbf{p}, P_2 \mathbf{q} \right) \\
515    ///           \mathbf{q} &= \cos{(\phi_2 \frac{\Delta t}{2})} \mathbf{q}^{'} +  \sin{(\phi_2 \frac{\Delta t}{2})} P_2 \mathbf{q}^{'} \nonumber \\
516    ///           \mathbf{p} &= \cos{(\phi_2 \frac{\Delta t}{2})} \mathbf{p}' +  \sin{(\phi_2 \frac{\Delta t}{2})} P_2 \mathbf{p}' \nonumber \\ \nonumber \\
517    ///
518    ///           \phi_1 &= \frac{1}{4 I_{11}} \mathrm{dot} \left( \mathbf{p}, P_1 \mathbf{q} \right) \\
519    ///           \mathbf{q} &= \cos{(\phi_1 \Delta t)} \mathbf{q}^{'} +  \sin{(\phi_1 \Delta t)} P_1 \mathbf{q}^{'} \nonumber \\
520    ///           \mathbf{p} &= \cos{(\phi_1 \Delta t)} \mathbf{p}' +  \sin{(\phi_1 \Delta t)} P_1 \mathbf{p}' \nonumber  \nonumber \\ \nonumber \\
521    ///
522    ///           \phi_2 &= \frac{1}{4 I_{22}} \mathrm{dot} \left( \mathbf{p}, P_2 \mathbf{q} \right) \\
523    ///           \mathbf{q} &= \cos{(\phi_2 \frac{\Delta t}{2})} \mathbf{q}^{'} +  \sin{(\phi_2 \frac{\Delta t}{2})} P_2 \mathbf{q}^{'} \nonumber \\
524    ///           \mathbf{p} &= \cos{(\phi_2 \frac{\Delta t}{2})} \mathbf{p}' +  \sin{(\phi_2 \frac{\Delta t}{2})} P_2 \mathbf{p}' \nonumber  \nonumber \\ \nonumber \\
525    ///
526    ///           \phi_3 &= \frac{1}{4 I_{33}} \mathrm{dot} \left( \mathbf{p}, P_3 \mathbf{q} \right) \\
527    ///           \mathbf{q} \left( t + \Delta t \right) &= \cos{(\phi_3 \frac{\Delta t}{2})} \mathbf{q}^{'} +  \sin{(\phi_3 \frac{\Delta t}{2})} P_3 \mathbf{q}^{'} \nonumber \\
528    ///           \mathbf{p} \left( t + \frac{\Delta t}{2} \right) &= \cos{(\phi_3 \frac{\Delta t}{2})} \mathbf{p}' +  \sin{(\phi_3 \frac{\Delta t}{2})} P_3 \mathbf{p}' \nonumber    \nonumber \\ \nonumber \\
529    ///
530    ///           \end{align*}
531    ///           ```
532    ///
533    ///           where $`I_{kk}`$ is the component of the moment of inertia for $`k = 1, 2, 3`$ and $`P_k`$ is the corresponding
534    ///           permutation matrix such that
535    ///
536    ///           ```math
537    ///           \begin{align*}
538    ///
539    ///           P_0\mathbf{q} &= (q_0, q_1, q_2, q_3) \\
540    ///           P_1\mathbf{q} &= (-q_1, q_0, q_3, -q_2) \\
541    ///           P_2\mathbf{q} &= (-q_2, -q_3, q_0, q_1) \\
542    ///           P_3\mathbf{q} &= (-q_3, q_2, -q_1, q_0) \\
543    ///           (PP^T)_{\alpha \beta} &= \delta_{\alpha \beta} \\
544    ///
545    ///           \end{align*}
546    ///            ```
547    ///
548    ///     3. $`\mathbf{p}`$ is converted back into vector-form angular momentum:
549    ///
550    ///        ```math
551    ///        \mathbf{L} \left( t + \frac{\Delta t}{2} \right) = \frac{1}{2} \mathbf{S}(\mathbf{q})^T \mathbf{p} \left( t + \frac{\Delta t}{2} \right)
552    ///        ```
553    ///
554    ///        where
555    ///
556    ///        ```math
557    ///        \begin{align*}
558    ///        \mathbf{L} &= (0, L_x, L_y, L_z) \\
559    ///        \vec{L} &= (L_x, L_y, L_z)
560    ///        \end{align*}
561    ///        ```
562    #[inline]
563    fn integrate_rotation_half_step_one_with_filter<
564        F: Fn(&Tagged<Body<DynamicOrientedPoint<Cartesian<3>, Versor>, S>>) -> bool,
565    >(
566        &mut self,
567        microstate: &mut Microstate<DynamicOrientedPoint<Cartesian<3>, Versor>, S, X, C>,
568        macrostate: &M,
569        should_integrate_body: F,
570    ) {
571        let mut rng = microstate.counter().make_rng();
572        let (kinetic_energy, degrees_of_freedom) =
573            microstate.rotational_kinetic_energy_with_filter(&should_integrate_body);
574        let rescaling_factor = self.rotational_thermostat.integrate_half_step_one(
575            &mut rng,
576            macrostate,
577            self.delta_t,
578            kinetic_energy,
579            degrees_of_freedom,
580        );
581
582        for body_index in 0..microstate.bodies().len() {
583            let body = &microstate.bodies()[body_index];
584            if !should_integrate_body(body) {
585                continue;
586            }
587            let mut body_properties = body.item.properties;
588
589            let (net_torque, active) =
590                body_net_torque_and_active_degrees_of_freedom(&body_properties);
591            let mut q = *body_properties.orientation().get();
592            let moment_of_inertia = body_properties.moment_of_inertia();
593
594            // DynamicOrientedPoint stores angular momentum in vector form. Convert it
595            // into a quaternion, integrate the quaternion, then store it back as a vector.
596            let s = *body_properties.angular_momentum();
597            let mut p = (q * Quaternion::pure(s)) * 2.0;
598
599            p = p * rescaling_factor + q * Quaternion::pure(net_torque) * self.delta_t;
600
601            if active[2] {
602                let p3 = Quaternion::from([-p.vector[2], p.vector[1], -p.vector[0], p.scalar]);
603                let q3 = Quaternion::from([-q.vector[2], q.vector[1], -q.vector[0], q.scalar]);
604                let phi3 = (1. / (4. * moment_of_inertia[2]))
605                    * ((p.scalar * q3.scalar) + p.vector.dot(&q3.vector));
606                let c_phi3 = (0.5 * self.delta_t * phi3).cos();
607                let s_phi3 = (0.5 * self.delta_t * phi3).sin();
608
609                p = p * c_phi3 + p3 * s_phi3;
610                q = q * c_phi3 + q3 * s_phi3;
611            }
612
613            if active[1] {
614                let p2 = Quaternion::from([-p.vector[1], -p.vector[2], p.scalar, p.vector[0]]);
615                let q2 = Quaternion::from([-q.vector[1], -q.vector[2], q.scalar, q.vector[0]]);
616                let phi2 = (1. / (4. * moment_of_inertia[1]))
617                    * ((p.scalar * q2.scalar) + p.vector.dot(&q2.vector));
618                let c_phi2 = (0.5 * self.delta_t * phi2).cos();
619                let s_phi2 = (0.5 * self.delta_t * phi2).sin();
620
621                p = p * c_phi2 + p2 * s_phi2;
622                q = q * c_phi2 + q2 * s_phi2;
623            }
624
625            if active[0] {
626                let p1 = Quaternion::from([-p.vector[0], p.scalar, p.vector[2], -p.vector[1]]);
627                let q1 = Quaternion::from([-q.vector[0], q.scalar, q.vector[2], -q.vector[1]]);
628                let phi1 = (1. / (4. * moment_of_inertia[0]))
629                    * ((p.scalar * q1.scalar) + p.vector.dot(&q1.vector));
630                let c_phi1 = (self.delta_t * phi1).cos();
631                let s_phi1 = (self.delta_t * phi1).sin();
632
633                p = p * c_phi1 + p1 * s_phi1;
634                q = q * c_phi1 + q1 * s_phi1;
635            }
636
637            if active[1] {
638                let p2 = Quaternion::from([-p.vector[1], -p.vector[2], p.scalar, p.vector[0]]);
639                let q2 = Quaternion::from([-q.vector[1], -q.vector[2], q.scalar, q.vector[0]]);
640                let phi2 = (1. / (4. * moment_of_inertia[1]))
641                    * ((p.scalar * q2.scalar) + p.vector.dot(&q2.vector));
642                let c_phi2 = (0.5 * self.delta_t * phi2).cos();
643                let s_phi2 = (0.5 * self.delta_t * phi2).sin();
644
645                p = p * c_phi2 + p2 * s_phi2;
646                q = q * c_phi2 + q2 * s_phi2;
647            }
648
649            if active[2] {
650                let p3 = Quaternion::from([-p.vector[2], p.vector[1], -p.vector[0], p.scalar]);
651                let q3 = Quaternion::from([-q.vector[2], q.vector[1], -q.vector[0], q.scalar]);
652                let phi3 = (1. / (4. * moment_of_inertia[2]))
653                    * ((p.scalar * q3.scalar) + p.vector.dot(&q3.vector));
654                let c_phi3 = (0.5 * self.delta_t * phi3).cos();
655                let s_phi3 = (0.5 * self.delta_t * phi3).sin();
656
657                p = p * c_phi3 + p3 * s_phi3;
658                q = q * c_phi3 + q3 * s_phi3;
659            }
660
661            *body_properties.orientation_mut() =
662                q.to_versor().expect("body orientation should be non-zero");
663            *body_properties.angular_momentum_mut() = ((q.conjugate() * p) * 0.5).vector;
664
665            microstate
666                .update_body_properties(body_index, body_properties)
667                .expect(
668                    "Bodies and sites should remain in simulation boundary.\n
669                Add interactions that prevent sites from moving outside the boundary.",
670                );
671        }
672
673        microstate.increment_substep();
674    }
675
676    /// Integrate selected body angular momenta forward a half step.
677    ///
678    /// The second half step of the symplectic integration procedure is given by the equations below, which are
679    /// applied to each selected body *i*. In each step, the marker $`'`$ is used when a variable's value changes
680    /// during a step to distinguish the value before ( $`'`$ is present) from the value after ( $`'`$ is absent).
681    /// The time $`t + \frac{\Delta t}{2}`$ is implicit on every variable unless otherwise specified.
682    /// Rotational degrees of freedom with a moment of inertia component of zero are skipped.
683    ///
684    /// 1. Angular momentum and net torque are converted to quaternions $`\mathbf{p}`$ and
685    ///    $`\mathbf{f}`$, respectively:
686    ///
687    ///    ```math
688    ///    \begin{align*}
689    ///
690    ///    \mathbf{p} &= 2\mathbf{S}(\mathbf{q}) \mathbf{L} \\
691    ///    \mathbf{f} &= 2\mathbf{S}(\mathbf{q}) \boldsymbol{\tau} \\
692    ///
693    ///    \end{align*}
694    ///    ```
695    ///
696    ///    where
697    ///
698    ///    ```math
699    ///    \begin{align*}
700    ///
701    ///    \mathbf{L} &= (0, L_x, L_y, L_z) \\
702    ///    \boldsymbol{\tau} &= (0, \tau_x, \tau_y, \tau_z) \\
703    ///
704    ///    \mathbf{S}(\mathbf{q}) &=
705    ///    \begin{pmatrix}
706    ///    q_0 & -q_1 & -q_2 & -q_3\\
707    ///    q_1 & q_0 & -q_3 & q_2\\
708    ///    q_2 & q_3 & q_0 & -q_1\\
709    ///    q_3 & -q_2 & q_1 & q_0
710    ///    \end{pmatrix}
711    ///
712    ///    \end{align*}
713    ///     ```
714    ///
715    /// 2. $`\mathbf{p}`$ is integrated forward a half step.
716    ///
717    ///    ```math
718    ///    \mathbf{p}\left( t + \Delta t \right) = \mathbf{p}\left( t + \frac{\Delta t}{2} \right) + \frac{\Delta t}{2} \mathbf{f}
719    ///    ```
720    ///
721    /// 3. $`\mathbf{p}`$ is converted back into vector-form angular momentum:
722    ///
723    ///    ```math
724    ///    \mathbf{L} \left( t + \Delta t \right) = \frac{1}{2} \mathbf{S}(\mathbf{q})^T \mathbf{p} \left( t + \Delta t \right)
725    ///    ```
726    ///
727    ///    where
728    ///
729    ///    ```math
730    ///    \begin{align*}
731    ///    \mathbf{L} &= (0, L_x, L_y, L_z) \\
732    ///    \vec{L} &= (L_x, L_y, L_z)
733    ///    \end{align*}
734    ///    ```
735    ///
736    /// 4. The rotational thermostat is integrated forward a half-step and then angular momentum is rescaled
737    ///    accordingly. (Note: `rotational_thermostat.integrate_half_step_two()` is the first half step method
738    ///    implemented by `TR`.)
739    ///
740    ///    ```math
741    ///    \vec{L}_i(t + \Delta t) = \vec{L}'_i(t + \Delta t) \cdot \mathrm{rotational\_thermostat.integrate\_half\_step\_two}\left(\sum_{i \in \mathrm{selection}} K'_{rot,j}(t + \Delta t) \right)
742    ///    ```
743    ///
744    ///    where the summation represents the total [rotational kinetic energy](crate::compute::RotationalKineticEnergy)
745    ///    of the selected bodies at the start of the step, and `rotational_thermostat.integrate_half_step_two()` is the
746    ///    second half step method implemented by `TR`.
747    #[inline]
748    fn integrate_rotation_half_step_two_with_filter<
749        F: Fn(&Tagged<Body<DynamicOrientedPoint<Cartesian<3>, Versor>, S>>) -> bool,
750    >(
751        &mut self,
752        microstate: &mut Microstate<DynamicOrientedPoint<Cartesian<3>, Versor>, S, X, C>,
753        macrostate: &M,
754        should_integrate_body: F,
755    ) {
756        let mut rng = microstate.counter().make_rng();
757
758        for body_index in 0..microstate.bodies().len() {
759            let body = &microstate.bodies()[body_index];
760            if !should_integrate_body(body) {
761                continue;
762            }
763            let mut body_properties = body.item.properties;
764
765            let (net_torque, _) = body_net_torque_and_active_degrees_of_freedom(&body_properties);
766            let q = *body_properties.orientation().get();
767            let s = *body_properties.angular_momentum();
768
769            let mut p = q * Quaternion::pure(s) * 2.0;
770
771            p += (q * Quaternion::pure(net_torque)) * self.delta_t;
772
773            *body_properties.angular_momentum_mut() = ((q.conjugate() * p) * 0.5).vector;
774
775            microstate
776                .update_body_properties(body_index, body_properties)
777                .expect(
778                    "Bodies and sites should remain in simulation boundary.\n
779                Add interactions that prevent sites from moving outside the boundary.",
780                );
781        }
782
783        let (kinetic_energy, degrees_of_freedom) =
784            microstate.rotational_kinetic_energy_with_filter(&should_integrate_body);
785        let rescaling_factor = self.rotational_thermostat.integrate_half_step_two(
786            &mut rng,
787            macrostate,
788            self.delta_t,
789            kinetic_energy,
790            degrees_of_freedom,
791        );
792
793        if rescaling_factor != 1.0 {
794            for body_index in 0..microstate.bodies().len() {
795                let body = &microstate.bodies()[body_index];
796                if !should_integrate_body(body) {
797                    continue;
798                }
799                let mut body_properties = body.item.properties;
800
801                *body_properties.angular_momentum_mut() *= rescaling_factor;
802
803                microstate
804                    .update_body_properties(body_index, body_properties)
805                    .expect(
806                        "Bodies and sites should remain in simulation boundary.\n
807                    Add interactions that prevent sites from moving outside the boundary.",
808                    );
809            }
810        }
811
812        microstate.increment_substep();
813    }
814}
815
816/// Rotational motion in 2-dimensional cartesian space.
817impl<S, X, C, TT, TR, M> RotationalMotion<DynamicOrientedPoint<Cartesian<2>, Angle>, S, X, C, M>
818    for ConstantVolume<TT, TR>
819where
820    DynamicOrientedPoint<Cartesian<2>, Angle>: Transform<S>,
821    S: Position<Position = Cartesian<2>> + Default,
822    X: PointUpdate<Cartesian<2>, SiteKey>,
823    C: Wrap<DynamicOrientedPoint<Cartesian<2>, Angle>> + Wrap<S> + GenerateGhosts<S>,
824    TR: Thermostat<M>,
825{
826    /// Integrate selected body orientations forward a full step and their angular momenta forward a half step.
827    ///
828    /// The first half step of the symplectic integration procedure is given by the equations below, which are
829    /// applied to each selected body *i*. In each step, the marker $`'`$ is used when a variable's value changes
830    /// during a step to distinguish the value before ( $`'`$ is present) from the value after ( $`'`$ is absent).
831    /// Selected bodies which have ``moment_of_inertia = 0.0`` are skipped.
832    ///
833    /// 1. The rotational thermostat is integrated forward a half-step and then angular momentum is rescaled
834    ///    accordingly:
835    ///
836    ///    ```math
837    ///    L_i(t) = L'_i(t) \cdot \mathrm{rotational\_thermostat.integrate\_half\_step\_one}\left(\sum_{j \in \mathrm{selection}} K'_{rot,j}(t) \right)
838    ///    ```
839    ///
840    ///    where the summation represents the total [rotational kinetic energy](crate::compute::RotationalKineticEnergy)
841    ///    of the selected bodies at the start of the step, and `rotational_thermostat.integrate_half_step_one()` is the
842    ///    first half step method implemented by `TR`.
843    ///
844    /// 2. Angular momentum is integrated forward a half step.
845    ///
846    ///    ```math
847    ///    L_i\left(t + \frac{\Delta t}{2} \right) = L_i(t) + \tau_i(t) \frac{\Delta t}{2}
848    ///    ```
849    ///
850    /// 3. Orientation is integrated forward a full step using the new angular momentum.
851    ///
852    ///    ```math
853    ///    \theta_i(t + \Delta t) = \theta_i(t) + \frac{L_i\left( t + \frac{\Delta t}{2} \right)}{I_i} \Delta t
854    ///    ```
855    #[inline]
856    fn integrate_rotation_half_step_one_with_filter<
857        F: Fn(&Tagged<Body<DynamicOrientedPoint<Cartesian<2>, Angle>, S>>) -> bool,
858    >(
859        &mut self,
860        microstate: &mut Microstate<DynamicOrientedPoint<Cartesian<2>, Angle>, S, X, C>,
861        macrostate: &M,
862        should_integrate_body: F,
863    ) {
864        let mut rng = microstate.counter().make_rng();
865        let (kinetic_energy, degrees_of_freedom) =
866            microstate.rotational_kinetic_energy_with_filter(&should_integrate_body);
867        let rescaling_factor = self.rotational_thermostat.integrate_half_step_one(
868            &mut rng,
869            macrostate,
870            self.delta_t,
871            kinetic_energy,
872            degrees_of_freedom,
873        );
874
875        for body_index in 0..microstate.bodies().len() {
876            let body = &microstate.bodies()[body_index];
877            if !should_integrate_body(body) {
878                continue;
879            }
880
881            let mut body_properties = body.item.properties;
882
883            let moment_of_inertia = *body_properties.moment_of_inertia();
884            if moment_of_inertia == 0.0 {
885                continue;
886            }
887
888            let net_torque = *body_properties.net_torque();
889
890            *body_properties.angular_momentum_mut() *= rescaling_factor;
891            *body_properties.angular_momentum_mut() += net_torque * 0.5 * self.delta_t;
892            body_properties.orientation_mut().theta +=
893                *body_properties.angular_momentum() / moment_of_inertia * self.delta_t;
894
895            *body_properties.orientation_mut() = body_properties.orientation_mut().to_reduced();
896
897            microstate
898                .update_body_properties(body_index, body_properties)
899                .expect(
900                    "Bodies and sites should remain in simulation boundary.\n
901                Add interactions that prevent sites from moving outside the boundary.",
902                );
903        }
904
905        microstate.increment_substep();
906    }
907
908    /// Integrate selected body angular momenta forward a half step.
909    ///
910    /// The second half step of the symplectic integration procedure is given by the equations below, which are
911    /// applied to each selected body *i*. In each step, the marker $`'`$ is used when a variable's value changes
912    /// during a step to distinguish the value before ( $`'`$ is present) from the value after ( $`'`$ is absent).
913    /// Selected bodies which have ``moment_of_inertia = 0.0`` are skipped.
914    ///
915    /// 1. Angular momentum is integrated forward a half step.
916    ///
917    ///    ```math
918    ///    L_i(t + \Delta t) = L_i\left( t + \frac{\Delta t}{2} \right) + \tau_i \left(t + \frac{\Delta t}{2} \right) \frac{\Delta t}{2}
919    ///    ```
920    ///
921    /// 2. The rotational thermostat is integrated forward a half step and then angular momentum
922    ///    is rescaled accordingly.
923    ///
924    ///    ```math
925    ///    L_i(t + \Delta t) = L'_i(t + \Delta t) \cdot \mathrm{rotational\_thermostat.integrate\_half\_step\_two}\left(\sum_{j \in \mathrm{selection}}K'_{rot,j}(t + \Delta t) \right)
926    ///    ```
927    ///
928    ///    where the summation represents the total [rotational kinetic energy](crate::compute::RotationalKineticEnergy)
929    ///    of the selected bodies at the start of the step, and `rotational_thermostat.integrate_half_step_two()` is the
930    ///    second half step method implemented by `TR`.
931    #[inline]
932    fn integrate_rotation_half_step_two_with_filter<
933        F: Fn(&Tagged<Body<DynamicOrientedPoint<Cartesian<2>, Angle>, S>>) -> bool,
934    >(
935        &mut self,
936        microstate: &mut Microstate<DynamicOrientedPoint<Cartesian<2>, Angle>, S, X, C>,
937        macrostate: &M,
938        should_integrate_body: F,
939    ) {
940        let mut rng = microstate.counter().make_rng();
941
942        for body_index in 0..microstate.bodies().len() {
943            let body = &microstate.bodies()[body_index];
944            if !should_integrate_body(body) {
945                continue;
946            }
947
948            let mut body_properties = body.item.properties;
949
950            let moment_of_inertia = *body_properties.moment_of_inertia();
951            if moment_of_inertia == 0.0 {
952                continue;
953            }
954
955            let net_torque = *body_properties.net_torque();
956
957            *body_properties.angular_momentum_mut() += net_torque * 0.5 * self.delta_t;
958
959            microstate
960                .update_body_properties(body_index, body_properties)
961                .expect(
962                    "Bodies and sites should remain in simulation boundary.\n
963                Add interactions that prevent sites from moving outside the boundary.",
964                );
965        }
966
967        let (kinetic_energy, degrees_of_freedom) = microstate.rotational_kinetic_energy();
968        let rescaling_factor = self.rotational_thermostat.integrate_half_step_two(
969            &mut rng,
970            macrostate,
971            self.delta_t,
972            kinetic_energy,
973            degrees_of_freedom,
974        );
975
976        for body_index in 0..microstate.bodies().len() {
977            let body = &microstate.bodies()[body_index];
978            if !should_integrate_body(body) {
979                continue;
980            }
981            let mut body_properties = body.item.properties;
982
983            *body_properties.angular_momentum_mut() *= rescaling_factor;
984
985            microstate
986                .update_body_properties(body_index, body_properties)
987                .expect(
988                    "Bodies and sites should remain in simulation boundary.\n
989                Add interactions that prevent sites from moving outside the boundary.",
990                );
991        }
992
993        microstate.increment_substep();
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use crate::{UpdateNetForceAndVirial, UpdateNetForceVirialAndTorque};
1001    use hoomd_interaction::{
1002        External, Rigid, Zero,
1003        external::{ConstantForce, ConstantTorque},
1004    };
1005    use hoomd_microstate::{
1006        Body,
1007        property::{DynamicOrientedPoint, DynamicPoint, Point},
1008    };
1009    use hoomd_vector::Outer;
1010
1011    use approxim::assert_relative_eq;
1012
1013    fn dynamics_body<const N: usize>(
1014        mass: f64,
1015    ) -> Body<DynamicPoint<Cartesian<N>>, Point<Cartesian<N>>> {
1016        Body {
1017            properties: DynamicPoint {
1018                mass,
1019                ..Default::default()
1020            },
1021            sites: vec![Point::new(Cartesian::default())],
1022        }
1023    }
1024
1025    fn oriented_dynamics_body_2d(
1026        mass: f64,
1027        moment_of_inertia: f64,
1028    ) -> Body<DynamicOrientedPoint<Cartesian<2>, Angle>, Point<Cartesian<2>>> {
1029        Body {
1030            properties: DynamicOrientedPoint {
1031                position: Cartesian::<2>::default(),
1032                orientation: Angle::default(),
1033                momentum: Cartesian::<2>::default(),
1034                net_force: Cartesian::<2>::default(),
1035                net_virial: Cartesian::<2>::default().outer(&Cartesian::<2>::default()),
1036                moment_of_inertia,
1037                angular_momentum: 0.0,
1038                net_torque: 0.0,
1039                mass,
1040            },
1041            sites: vec![Point::new(Cartesian::from([0.0, 0.0]))],
1042        }
1043    }
1044
1045    #[test]
1046    fn test_constant_volume() {
1047        let dt = 2.0;
1048        let cv = ConstantVolume::builder(dt).build();
1049        assert_eq!(cv.delta_t, dt);
1050    }
1051
1052    #[test]
1053    fn test_translational_integration() -> anyhow::Result<()> {
1054        // Ensure translational integration of a simple external force in 3D
1055        // yields the correct position and momentum at the half step and the
1056        // full step.
1057        let mass = 1.0;
1058        let dt = 0.1;
1059        let force = Cartesian::<3>::from([
1060            1.0 / 3.0_f64.sqrt(),
1061            1.0 / 3.0_f64.sqrt(),
1062            1.0 / 3.0_f64.sqrt(),
1063        ]);
1064
1065        let mut microstate = Microstate::builder()
1066            .bodies([dynamics_body(mass)])
1067            .try_build()?;
1068        let rigid = Rigid(External(ConstantForce {
1069            force,
1070            r_0: [0.0, 0.0, 0.0].into(),
1071        }));
1072        let mut method = ConstantVolume::builder(dt).build();
1073        let macrostate = ();
1074
1075        // Update force first so that the particles can move
1076        microstate.update_net_force_and_virial(&rigid);
1077
1078        // Check the first half step
1079        method.integrate_translation_half_step_one(&mut microstate, &macrostate);
1080        let mut expected_momentum = Cartesian::<3>::default() + (force * dt * 0.5);
1081        let expected_position = Cartesian::<3>::default() + expected_momentum * dt / mass;
1082
1083        assert_relative_eq!(
1084            expected_momentum,
1085            microstate.bodies()[0].item.properties.momentum
1086        );
1087        assert_relative_eq!(
1088            expected_position,
1089            microstate.bodies()[0].item.properties.position
1090        );
1091
1092        // Update force again
1093        microstate.update_net_force_and_virial(&rigid);
1094
1095        // Check the second half step
1096        method.integrate_translation_half_step_two(&mut microstate, &macrostate);
1097        expected_momentum += force * dt * 0.5;
1098        assert_relative_eq!(
1099            expected_momentum,
1100            microstate.bodies()[0].item.properties.momentum
1101        );
1102        assert_relative_eq!(
1103            expected_position,
1104            microstate.bodies()[0].item.properties.position
1105        );
1106
1107        assert_eq!(microstate.conserved_degrees_of_freedom(), 3);
1108
1109        Ok(())
1110    }
1111
1112    #[test]
1113    fn test_conserved_degrees_of_freedom() -> anyhow::Result<()> {
1114        let mass = 1.0;
1115        let dt = 0.1;
1116
1117        let mut microstate = Microstate::builder()
1118            .bodies([
1119                dynamics_body::<4>(mass),
1120                dynamics_body(mass),
1121                dynamics_body(mass),
1122            ])
1123            .try_build()?;
1124
1125        let mut method = ConstantVolume::builder(dt).build();
1126        let macrostate = ();
1127        let rigid = Rigid(Zero);
1128
1129        assert_eq!(microstate.conserved_degrees_of_freedom(), 0);
1130
1131        method
1132            .integrate_translation_with_filter(&mut microstate, &macrostate, &rigid, |b| b.tag < 2);
1133
1134        assert_eq!(microstate.conserved_degrees_of_freedom(), 0);
1135
1136        method
1137            .integrate_translation_with_filter(&mut microstate, &macrostate, &rigid, |b| b.tag < 3);
1138
1139        assert_eq!(microstate.conserved_degrees_of_freedom(), 4);
1140
1141        Ok(())
1142    }
1143
1144    #[test]
1145    fn test_rotational_integration_2d() -> anyhow::Result<()> {
1146        // Ensure rotational integration of a simple external torque in 2D
1147        // yields the correct orientation and angular momentum at the half step
1148        // and the full step
1149        let mass = 1.0;
1150        let moi = 1.0;
1151        let dt = 0.1;
1152        let t_mag = 1.0;
1153        let t_dir = 1.0;
1154
1155        let mut microstate = Microstate::builder()
1156            .bodies([oriented_dynamics_body_2d(mass, moi)])
1157            .try_build()?;
1158        let torque = Rigid(External(ConstantTorque {
1159            torque: t_dir * t_mag,
1160        }));
1161        let mut method = ConstantVolume::builder(dt).build();
1162        let macrostate = ();
1163
1164        // Update torque first so that the particles can move
1165        microstate.update_net_force_virial_and_torque(&torque);
1166
1167        // Check the first half step
1168        method.integrate_rotation_half_step_one(&mut microstate, &macrostate);
1169        let mut expected_angular_momentum = t_dir * t_mag * 0.5 * dt;
1170        let expected_orientation = Angle::default().theta + expected_angular_momentum / moi * dt;
1171
1172        assert_eq!(
1173            expected_angular_momentum,
1174            microstate.bodies()[0].item.properties.angular_momentum
1175        );
1176        assert_eq!(
1177            expected_orientation,
1178            microstate.bodies()[0].item.properties.orientation.theta
1179        );
1180
1181        // Update torque again
1182        microstate.update_net_force_virial_and_torque(&torque);
1183
1184        // Check the second half step
1185        method.integrate_rotation_half_step_two(&mut microstate, &macrostate);
1186        expected_angular_momentum += t_dir * t_mag * 0.5 * dt;
1187        assert_eq!(
1188            expected_angular_momentum,
1189            microstate.bodies()[0].item.properties.angular_momentum
1190        );
1191        assert_eq!(
1192            expected_orientation,
1193            microstate.bodies()[0].item.properties.orientation.theta
1194        );
1195
1196        Ok(())
1197    }
1198}