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 = µstate.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 = µstate.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 = µstate.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 = µstate.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 = µstate.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 = µstate.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 = µstate.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 = µstate.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 = µstate.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, ¯ostate);
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, ¯ostate);
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, ¯ostate, &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, ¯ostate, &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, ¯ostate);
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, ¯ostate);
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}