Skip to main content

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}