alice_physics/pair_potential.rs
1//! Pair potentials between point particles: Lennard-Jones 12-6, Morse,
2//! Coulomb and screened Coulomb (Yukawa), with cutoff / shift and the
3//! Lorentz–Berthelot mixing rule.
4//!
5//! # Laws
6//!
7//! ```text
8//! Lennard-Jones U = 4ε[(σ/r)¹² − (σ/r)⁶] F = 24ε[2(σ/r)¹² − (σ/r)⁶] / r
9//! Morse U = D(1 − e)² − D, e = e^{−a(r − r_e)} F = −2 D a e (1 − e)
10//! Coulomb U = k q_i q_j / r F = k q_i q_j / r²
11//! Yukawa U = k q_i q_j e^{−r/λ} / r F = k q_i q_j e^{−r/λ} (1/r² + 1/(λ r))
12//! ```
13//!
14//! `F = −dU/dr` is the radial force: positive is repulsive. The force on
15//! particle `i` from `j` is `F(r) r̂` with `r̂ = (x_i − x_j)/r`
16//! ([`PairPotential::force_on_first`]); `j` receives the negative.
17//!
18//! - Lennard-Jones: minimum `U = −ε` at `r_min = 2^{1/6} σ`, `U(σ) = 0`,
19//! curvature `U''(r_min) = 72 ε / r_min² ≈ 57.146 ε/σ²`.
20//! - Morse is written with its minimum at `−D` (`U(r_e) = −D`, `U → 0` as
21//! `r → ∞`), so it has the same zero at infinity as the others and can be
22//! truncated the same way. The harmonic force constant at `r_e` is
23//! `2 D a²`.
24//! - Coulomb uses [`COULOMB_CONSTANT`] `k = 8.99e9 V·m/C`, the same constant
25//! and sign convention as [`crate::electromagnetic::EmSource::PointCharge`]:
26//! the force on `i` equals `q_i E_j(x_i)` with `E_j` the field of charge
27//! `j`. `Coulomb::with_constant` takes `k` explicitly (reduced units).
28//! - Yukawa is the Debye–Hückel screened Coulomb interaction of charges in an
29//! electrolyte or plasma with screening length `λ`; `λ → ∞` recovers
30//! Coulomb. It is included because it is a law of the same form
31//! (parameterised by `λ` alone) and its long-range part decays
32//! exponentially, which makes a cutoff meaningful; the bare Coulomb sum
33//! with a cutoff is not convergent in a periodic system (Ewald / PPPM
34//! summation is not provided).
35//!
36//! # Cutoff and shift
37//!
38//! [`Truncated`] cuts a potential at `r_c` (zero for `r ≥ r_c`) with an
39//! explicit [`ShiftMode`]:
40//!
41//! ```text
42//! None U(r) F(r) (U jumps at r_c)
43//! EnergyShift U(r) − U(r_c) F(r) (F jumps at r_c)
44//! ForceShift U(r) − U(r_c) + (r − r_c) F(r_c) F(r) − F(r_c) (both vanish at r_c)
45//! ```
46//!
47//! (`ForceShift` is the shifted-force potential, `U'(r_c) = −F(r_c)`.)
48//!
49//! # Degenerate input
50//!
51//! Every evaluation takes `r > 0`; `r ≤ 0` (coincident particles) is
52//! [`PairPotentialError::NonPositiveDistance`]: the direction of the force is
53//! undefined there and Lennard-Jones / Coulomb diverge. A value outside the
54//! `Fix128` range (`|x| ≥ 2⁶³`, e.g. `(σ/r)¹²` for `r < σ/1500`) is
55//! [`PairPotentialError::Overflow`] instead of a wrapped number. Parameters
56//! are validated by the constructors (`σ > 0`, `ε ≥ 0`, `D ≥ 0`, `a > 0`,
57//! `r_e > 0`, `k > 0`, `λ > 0`, `r_c > 0`); `ε = 0` / `D = 0` / zero charges
58//! give a potential that is zero everywhere.
59//!
60//! # Determinism
61//!
62//! `Fix128` throughout (no `std` needed). `exp` is
63//! [`crate::math_util::exp_fix`], extended above its saturation at 20 by
64//! halving the argument and squaring the result (the same steps `exp_fix`
65//! itself takes, so the extension is continuous at 20).
66//!
67//! Specific molecules' `ε`, `σ`, charges or force-field tables are not part
68//! of this module: every parameter is passed in.
69
70use crate::math::{Fix128, Vec3Fix};
71use crate::math_util::exp_fix;
72
73/// Coulomb constant `k = 1/(4π ε₀) ≈ 8.99e9 V·m/C`, the value used by
74/// [`crate::electromagnetic::EmSource::PointCharge`].
75pub const COULOMB_CONSTANT: Fix128 = Fix128::from_int(8_990_000_000);
76
77/// Why a pair potential could not be built or evaluated.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum PairPotentialError {
80 /// Distance `r ≤ 0` (coincident particles).
81 NonPositiveDistance,
82 /// Lennard-Jones `σ ≤ 0`.
83 NonPositiveSigma,
84 /// Lennard-Jones `ε < 0`.
85 NegativeEpsilon,
86 /// Morse well depth `D < 0`.
87 NegativeWellDepth,
88 /// Morse width parameter `a ≤ 0`.
89 NonPositiveWidth,
90 /// Morse equilibrium distance `r_e ≤ 0`.
91 NonPositiveEquilibriumDistance,
92 /// Coulomb constant `k ≤ 0`.
93 NonPositiveCoulombConstant,
94 /// Yukawa screening length `λ ≤ 0`.
95 NonPositiveScreeningLength,
96 /// Cutoff radius `r_c ≤ 0`.
97 NonPositiveCutoff,
98 /// The value is outside the `Fix128` range.
99 Overflow,
100}
101
102impl core::fmt::Display for PairPotentialError {
103 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104 let msg = match self {
105 Self::NonPositiveDistance => "pair distance must be positive",
106 Self::NonPositiveSigma => "Lennard-Jones sigma must be positive",
107 Self::NegativeEpsilon => "Lennard-Jones epsilon must not be negative",
108 Self::NegativeWellDepth => "Morse well depth must not be negative",
109 Self::NonPositiveWidth => "Morse width parameter must be positive",
110 Self::NonPositiveEquilibriumDistance => "Morse equilibrium distance must be positive",
111 Self::NonPositiveCoulombConstant => "Coulomb constant must be positive",
112 Self::NonPositiveScreeningLength => "screening length must be positive",
113 Self::NonPositiveCutoff => "cutoff radius must be positive",
114 Self::Overflow => "pair potential value is outside the fixed-point range",
115 };
116 f.write_str(msg)
117 }
118}
119
120#[cfg(feature = "std")]
121impl std::error::Error for PairPotentialError {}
122
123/// A central pair potential `U(r)` and its radial force `F(r) = −dU/dr`.
124pub trait PairPotential {
125 /// Potential energy `U(r)` at distance `r > 0`.
126 fn energy(&self, r: Fix128) -> Result<Fix128, PairPotentialError>;
127
128 /// Radial force `F(r) = −dU/dr` at `r > 0` (positive = repulsive).
129 fn force(&self, r: Fix128) -> Result<Fix128, PairPotentialError>;
130
131 /// Force on the first particle of a pair separated by `d = x_i − x_j`:
132 /// `F(|d|) d / |d|`. The second particle receives the negative.
133 fn force_on_first(&self, d: Vec3Fix) -> Result<Vec3Fix, PairPotentialError> {
134 let r = d.length();
135 let f = self.force(r)?;
136 Ok(d * (f / r))
137 }
138}
139
140fn mul(a: Fix128, b: Fix128) -> Result<Fix128, PairPotentialError> {
141 a.checked_mul(b).ok_or(PairPotentialError::Overflow)
142}
143
144fn positive_distance(r: Fix128) -> Result<(), PairPotentialError> {
145 if r > Fix128::ZERO {
146 Ok(())
147 } else {
148 Err(PairPotentialError::NonPositiveDistance)
149 }
150}
151
152/// `1 / r` and `1 / r²` for `r > 0`, with the range checked: `1/r` itself
153/// stays in range for every positive `Fix128` (`1/2⁻⁶⁴ = 2⁶⁴` does not, so
154/// the smallest distances are an overflow).
155fn inverse(r: Fix128) -> Result<Fix128, PairPotentialError> {
156 // 1 / r ≥ 2⁶³ ⇔ r ≤ 2⁻⁶³
157 if r <= Fix128::from_raw(0, 2) {
158 return Err(PairPotentialError::Overflow);
159 }
160 Ok(Fix128::ONE / r)
161}
162
163/// `e^x` for every `x`: [`exp_fix`] below its saturation at 20, above that
164/// by halving `x` and squaring the result back (overflow is an error).
165fn exp_extended(x: Fix128) -> Result<Fix128, PairPotentialError> {
166 let limit = Fix128::from_int(20);
167 let mut y = x;
168 let mut squarings = 0u32;
169 while y >= limit {
170 y = y.half();
171 squarings += 1;
172 }
173 let mut e = exp_fix(y);
174 for _ in 0..squarings {
175 e = mul(e, e)?;
176 }
177 Ok(e)
178}
179
180// ---------------------------------------------------------------------------
181// Lennard-Jones
182// ---------------------------------------------------------------------------
183
184/// Lennard-Jones 12-6 potential `U = 4ε[(σ/r)¹² − (σ/r)⁶]`.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct LennardJones {
187 epsilon: Fix128,
188 sigma: Fix128,
189}
190
191impl LennardJones {
192 /// Well depth `ε ≥ 0` and zero-crossing distance `σ > 0`.
193 ///
194 /// # Errors
195 ///
196 /// [`PairPotentialError::NonPositiveSigma`],
197 /// [`PairPotentialError::NegativeEpsilon`].
198 pub fn new(epsilon: Fix128, sigma: Fix128) -> Result<Self, PairPotentialError> {
199 if sigma <= Fix128::ZERO {
200 return Err(PairPotentialError::NonPositiveSigma);
201 }
202 if epsilon.is_negative() {
203 return Err(PairPotentialError::NegativeEpsilon);
204 }
205 Ok(Self { epsilon, sigma })
206 }
207
208 /// Well depth `ε`.
209 #[must_use]
210 pub const fn epsilon(&self) -> Fix128 {
211 self.epsilon
212 }
213
214 /// Zero-crossing distance `σ`.
215 #[must_use]
216 pub const fn sigma(&self) -> Fix128 {
217 self.sigma
218 }
219
220 /// `((σ/r)⁶, (σ/r)¹²)`.
221 fn powers(&self, r: Fix128) -> Result<(Fix128, Fix128), PairPotentialError> {
222 positive_distance(r)?;
223 let s = mul(self.sigma, inverse(r)?)?;
224 let s2 = mul(s, s)?;
225 let s6 = mul(mul(s2, s2)?, s2)?;
226 let s12 = mul(s6, s6)?;
227 Ok((s6, s12))
228 }
229}
230
231impl PairPotential for LennardJones {
232 fn energy(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
233 let (s6, s12) = self.powers(r)?;
234 mul(Fix128::from_int(4), mul(self.epsilon, s12 - s6)?)
235 }
236
237 fn force(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
238 let (s6, s12) = self.powers(r)?;
239 let bracket = s12.double() - s6;
240 let f = mul(mul(Fix128::from_int(24), self.epsilon)?, bracket)?;
241 mul(f, inverse(r)?)
242 }
243}
244
245/// Lorentz–Berthelot mixing rule for Lennard-Jones parameters of unlike
246/// species: `σ_ij = (σ_i + σ_j)/2`, `ε_ij = √(ε_i ε_j)`.
247#[must_use]
248pub fn lorentz_berthelot(a: &LennardJones, b: &LennardJones) -> LennardJones {
249 LennardJones {
250 sigma: (a.sigma + b.sigma).half(),
251 epsilon: (a.epsilon * b.epsilon).sqrt(),
252 }
253}
254
255// ---------------------------------------------------------------------------
256// Morse
257// ---------------------------------------------------------------------------
258
259/// Morse potential `U = D(1 − e^{−a(r − r_e)})² − D` (minimum `−D` at `r_e`).
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub struct Morse {
262 well_depth: Fix128,
263 width: Fix128,
264 equilibrium_distance: Fix128,
265}
266
267impl Morse {
268 /// Well depth `D ≥ 0`, width parameter `a > 0` (1/length) and
269 /// equilibrium distance `r_e > 0`.
270 ///
271 /// # Errors
272 ///
273 /// [`PairPotentialError::NegativeWellDepth`],
274 /// [`PairPotentialError::NonPositiveWidth`],
275 /// [`PairPotentialError::NonPositiveEquilibriumDistance`].
276 pub fn new(
277 well_depth: Fix128,
278 width: Fix128,
279 equilibrium_distance: Fix128,
280 ) -> Result<Self, PairPotentialError> {
281 if well_depth.is_negative() {
282 return Err(PairPotentialError::NegativeWellDepth);
283 }
284 if width <= Fix128::ZERO {
285 return Err(PairPotentialError::NonPositiveWidth);
286 }
287 if equilibrium_distance <= Fix128::ZERO {
288 return Err(PairPotentialError::NonPositiveEquilibriumDistance);
289 }
290 Ok(Self {
291 well_depth,
292 width,
293 equilibrium_distance,
294 })
295 }
296
297 /// Harmonic force constant at the minimum, `2 D a²`.
298 #[must_use]
299 pub fn harmonic_force_constant(&self) -> Fix128 {
300 (self.well_depth * self.width * self.width).double()
301 }
302
303 /// `e^{−a(r − r_e)}`.
304 fn decay(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
305 positive_distance(r)?;
306 exp_extended(mul(self.width, self.equilibrium_distance - r)?)
307 }
308}
309
310impl PairPotential for Morse {
311 fn energy(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
312 let one_minus = Fix128::ONE - self.decay(r)?;
313 Ok(mul(self.well_depth, mul(one_minus, one_minus)?)? - self.well_depth)
314 }
315
316 fn force(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
317 let e = self.decay(r)?;
318 let da2 = (self.well_depth * self.width).double();
319 Ok(-mul(da2, mul(e, Fix128::ONE - e)?)?)
320 }
321}
322
323// ---------------------------------------------------------------------------
324// Coulomb / Yukawa
325// ---------------------------------------------------------------------------
326
327/// Coulomb interaction `U = k q_i q_j / r` of two point charges.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub struct Coulomb {
330 /// `k q_i q_j`, formed as `(k q_i) q_j` so that small SI charges keep
331 /// their relative precision (`q_i q_j` alone is ~1e-12 C², only ~1e-8
332 /// relative in `Fix128`).
333 coupling: Fix128,
334}
335
336impl Coulomb {
337 /// Charges `q_i`, `q_j` in coulombs with [`COULOMB_CONSTANT`].
338 #[must_use]
339 pub fn new(charge_i: Fix128, charge_j: Fix128) -> Self {
340 Self {
341 coupling: COULOMB_CONSTANT * charge_i * charge_j,
342 }
343 }
344
345 /// Charges with an explicit constant `k > 0` (e.g. `k = 1` in reduced
346 /// units).
347 ///
348 /// # Errors
349 ///
350 /// [`PairPotentialError::NonPositiveCoulombConstant`].
351 pub fn with_constant(
352 coulomb_constant: Fix128,
353 charge_i: Fix128,
354 charge_j: Fix128,
355 ) -> Result<Self, PairPotentialError> {
356 if coulomb_constant <= Fix128::ZERO {
357 return Err(PairPotentialError::NonPositiveCoulombConstant);
358 }
359 Ok(Self {
360 coupling: coulomb_constant * charge_i * charge_j,
361 })
362 }
363}
364
365impl PairPotential for Coulomb {
366 fn energy(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
367 positive_distance(r)?;
368 mul(self.coupling, inverse(r)?)
369 }
370
371 fn force(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
372 positive_distance(r)?;
373 let inv = inverse(r)?;
374 mul(mul(self.coupling, inv)?, inv)
375 }
376}
377
378/// Screened Coulomb (Yukawa / Debye–Hückel) interaction
379/// `U = k q_i q_j e^{−r/λ} / r`.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub struct Yukawa {
382 coulomb: Coulomb,
383 screening_length: Fix128,
384}
385
386impl Yukawa {
387 /// Charges in coulombs with [`COULOMB_CONSTANT`] and screening length
388 /// `λ > 0`.
389 ///
390 /// # Errors
391 ///
392 /// [`PairPotentialError::NonPositiveScreeningLength`].
393 pub fn new(
394 charge_i: Fix128,
395 charge_j: Fix128,
396 screening_length: Fix128,
397 ) -> Result<Self, PairPotentialError> {
398 Self::from_coulomb(Coulomb::new(charge_i, charge_j), screening_length)
399 }
400
401 /// Charges with an explicit constant `k > 0` and screening length `λ > 0`.
402 ///
403 /// # Errors
404 ///
405 /// [`PairPotentialError::NonPositiveCoulombConstant`],
406 /// [`PairPotentialError::NonPositiveScreeningLength`].
407 pub fn with_constant(
408 coulomb_constant: Fix128,
409 charge_i: Fix128,
410 charge_j: Fix128,
411 screening_length: Fix128,
412 ) -> Result<Self, PairPotentialError> {
413 Self::from_coulomb(
414 Coulomb::with_constant(coulomb_constant, charge_i, charge_j)?,
415 screening_length,
416 )
417 }
418
419 fn from_coulomb(
420 coulomb: Coulomb,
421 screening_length: Fix128,
422 ) -> Result<Self, PairPotentialError> {
423 if screening_length <= Fix128::ZERO {
424 return Err(PairPotentialError::NonPositiveScreeningLength);
425 }
426 Ok(Self {
427 coulomb,
428 screening_length,
429 })
430 }
431
432 fn screening(&self, r: Fix128) -> Fix128 {
433 exp_fix(-(r / self.screening_length))
434 }
435}
436
437impl PairPotential for Yukawa {
438 fn energy(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
439 let u = self.coulomb.energy(r)?;
440 mul(u, self.screening(r))
441 }
442
443 fn force(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
444 let f = self.coulomb.force(r)?;
445 // F = k q q e^{−r/λ} (1/r²)(1 + r/λ)
446 let factor = Fix128::ONE + r / self.screening_length;
447 mul(mul(f, factor)?, self.screening(r))
448 }
449}
450
451// ---------------------------------------------------------------------------
452// Cutoff
453// ---------------------------------------------------------------------------
454
455/// How a [`Truncated`] potential is made to vanish at the cutoff.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum ShiftMode {
458 /// Plain truncation: `U(r)`, `F(r)` inside, both jump to 0 at `r_c`.
459 None,
460 /// `U(r) − U(r_c)`; the force is unchanged and jumps at `r_c`.
461 EnergyShift,
462 /// Shifted force: `U(r) − U(r_c) + (r − r_c) F(r_c)` and `F(r) − F(r_c)`;
463 /// both vanish at `r_c`.
464 ForceShift,
465}
466
467/// A pair potential cut at `r_c`: zero for `r ≥ r_c`, shifted inside by
468/// [`ShiftMode`].
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub struct Truncated<P> {
471 potential: P,
472 cutoff: Fix128,
473 mode: ShiftMode,
474 energy_at_cutoff: Fix128,
475 force_at_cutoff: Fix128,
476}
477
478impl<P: PairPotential> Truncated<P> {
479 /// Cut `potential` at `cutoff > 0` with `mode`.
480 ///
481 /// # Errors
482 ///
483 /// [`PairPotentialError::NonPositiveCutoff`], or the error of evaluating
484 /// the potential at the cutoff.
485 pub fn new(potential: P, cutoff: Fix128, mode: ShiftMode) -> Result<Self, PairPotentialError> {
486 if cutoff <= Fix128::ZERO {
487 return Err(PairPotentialError::NonPositiveCutoff);
488 }
489 let energy_at_cutoff = potential.energy(cutoff)?;
490 let force_at_cutoff = potential.force(cutoff)?;
491 Ok(Self {
492 potential,
493 cutoff,
494 mode,
495 energy_at_cutoff,
496 force_at_cutoff,
497 })
498 }
499
500 /// Cutoff radius `r_c`.
501 #[must_use]
502 pub const fn cutoff(&self) -> Fix128 {
503 self.cutoff
504 }
505
506 /// Shift mode.
507 #[must_use]
508 pub const fn mode(&self) -> ShiftMode {
509 self.mode
510 }
511
512 /// The uncut potential.
513 #[must_use]
514 pub const fn inner(&self) -> &P {
515 &self.potential
516 }
517}
518
519impl<P: PairPotential> PairPotential for Truncated<P> {
520 fn energy(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
521 positive_distance(r)?;
522 if r >= self.cutoff {
523 return Ok(Fix128::ZERO);
524 }
525 let u = self.potential.energy(r)?;
526 Ok(match self.mode {
527 ShiftMode::None => u,
528 ShiftMode::EnergyShift => u - self.energy_at_cutoff,
529 ShiftMode::ForceShift => {
530 u - self.energy_at_cutoff + mul(r - self.cutoff, self.force_at_cutoff)?
531 }
532 })
533 }
534
535 fn force(&self, r: Fix128) -> Result<Fix128, PairPotentialError> {
536 positive_distance(r)?;
537 if r >= self.cutoff {
538 return Ok(Fix128::ZERO);
539 }
540 let f = self.potential.force(r)?;
541 Ok(match self.mode {
542 ShiftMode::None | ShiftMode::EnergyShift => f,
543 ShiftMode::ForceShift => f - self.force_at_cutoff,
544 })
545 }
546}