Skip to main content

molpack/restraint/collective/
exponential.rs

1//! Exponential-target members of the distribution-matching family: the reaction
2//! coordinate ξ is driven to an exponential distribution (density `∝ e^(−ξ/λ)`,
3//! `ξ ≥ 0`) via the shared Wasserstein engine — a smooth decay away from a wall
4//! or centre (the diffuse-layer / Gouy–Chapman shape). The target quantile
5//! function is `q(p) = −λ·ln(1 − p)` for both geometries; only ξ and its gradient
6//! (supplied by [`geometry`](super::geometry)) differ.
7//!
8//! Because the quantiles are non-negative, both members place the densest copies
9//! at the plane/centre (`ξ = 0`) and thin them out with decay length `λ`.
10
11use molrs::types::F;
12
13use super::Restraint;
14use super::geometry::{plane_match_f, plane_match_fg, point_match_f, point_match_fg, unit};
15
16/// Exponential target quantile `q(p) = −λ·ln(1 − p)` (`≥ 0` for `p ∈ (0, 1)`).
17#[inline]
18fn exponential_quantile(p: F, lambda: F) -> F {
19    -lambda * (1.0 - p).ln()
20}
21
22// ============================================================================
23// ExponentialPlane — exponential decay of the signed distance to a plane
24// ============================================================================
25
26/// Drive a species' signed distance to a plane, `ξ = x·n̂ − offset`, to an
27/// exponential distribution `∝ e^(−ξ/λ)` (`ξ ≥ 0`) — a layer densest at the plane
28/// and decaying along `+n̂` with decay length `λ` (a diffuse layer).
29///
30/// `strength` (`λ_pen`) scales the squared-Wasserstein penalty; `scale`/`scale2`
31/// are accepted for trait symmetry but unused.
32#[derive(Debug, Clone)]
33pub struct ExponentialPlane {
34    normal: [F; 3],
35    offset: F,
36    strength: F,
37    lambda: F,
38}
39
40impl ExponentialPlane {
41    /// - `normal` — plane normal (normalised internally; need not be unit).
42    /// - `offset` — plane offset so `ξ = x·n̂ − offset` (the wall is at `ξ = 0`).
43    /// - `strength` — overall penalty multiplier.
44    /// - `lambda` — exponential decay length (`λ > 0`).
45    ///
46    /// # Panics
47    /// If the normal is the zero vector or `lambda <= 0`.
48    pub fn new(normal: [F; 3], offset: F, strength: F, lambda: F) -> Self {
49        assert!(lambda > 0.0, "ExponentialPlane lambda must be positive");
50        Self {
51            normal: unit(normal),
52            offset,
53            strength,
54            lambda,
55        }
56    }
57
58    #[inline]
59    fn quantile(&self) -> impl Fn(F) -> F + '_ {
60        move |p| exponential_quantile(p, self.lambda)
61    }
62}
63
64impl Restraint for ExponentialPlane {
65    fn f(&self, coords: &[[F; 3]], _scale: F, _scale2: F) -> F {
66        plane_match_f(
67            coords,
68            &self.normal,
69            self.offset,
70            self.strength,
71            self.quantile(),
72        )
73    }
74
75    fn fg(&self, coords: &[[F; 3]], _scale: F, _scale2: F, grads: &mut [[F; 3]]) -> F {
76        plane_match_fg(
77            coords,
78            &self.normal,
79            self.offset,
80            self.strength,
81            self.quantile(),
82            grads,
83        )
84    }
85
86    fn name(&self) -> &'static str {
87        "ExponentialPlane"
88    }
89}
90
91// ============================================================================
92// ExponentialPoint — exponential decay of the distance to a point
93// ============================================================================
94
95/// Drive a species' distance to a centre, `ξ = ‖x − c‖`, to an exponential
96/// distribution `∝ e^(−ξ/λ)` — densest at the centre and decaying radially with
97/// decay length `λ` (a radial atmosphere around `center`).
98#[derive(Debug, Clone)]
99pub struct ExponentialPoint {
100    center: [F; 3],
101    strength: F,
102    lambda: F,
103}
104
105impl ExponentialPoint {
106    /// - `center` — the point `c` the decay is measured from.
107    /// - `strength` — overall penalty multiplier.
108    /// - `lambda` — radial decay length (`λ > 0`).
109    ///
110    /// # Panics
111    /// If `lambda <= 0`.
112    pub fn new(center: [F; 3], strength: F, lambda: F) -> Self {
113        assert!(lambda > 0.0, "ExponentialPoint lambda must be positive");
114        Self {
115            center,
116            strength,
117            lambda,
118        }
119    }
120
121    #[inline]
122    fn quantile(&self) -> impl Fn(F) -> F + '_ {
123        move |p| exponential_quantile(p, self.lambda)
124    }
125}
126
127impl Restraint for ExponentialPoint {
128    fn f(&self, coords: &[[F; 3]], _scale: F, _scale2: F) -> F {
129        point_match_f(coords, &self.center, self.strength, self.quantile())
130    }
131
132    fn fg(&self, coords: &[[F; 3]], _scale: F, _scale2: F, grads: &mut [[F; 3]]) -> F {
133        point_match_fg(coords, &self.center, self.strength, self.quantile(), grads)
134    }
135
136    fn name(&self) -> &'static str {
137        "ExponentialPoint"
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::super::testutil::{assert_fd_grad, rng_uniform};
144    use super::*;
145
146    #[test]
147    fn quantile_is_nonnegative_and_increasing() {
148        let q = |p| exponential_quantile(p, 3.0);
149        assert!((q(0.0)).abs() < 1e-12); // q(0) = 0
150        assert!(q(0.25) > 0.0 && q(0.75) > q(0.25));
151    }
152
153    #[test]
154    fn plane_gradient_matches_finite_difference() {
155        let r = ExponentialPlane::new([0.0, 0.0, 1.0], 0.0, 1000.0, 4.0);
156        let mut seed = 0xABCD_1234u64;
157        // Increasing, well-separated ξ so no rank swap under a 1e-6 perturbation.
158        let coords: Vec<[F; 3]> = (0..20)
159            .map(|i| {
160                [
161                    rng_uniform(&mut seed, 0.0, 8.0),
162                    rng_uniform(&mut seed, 0.0, 8.0),
163                    1.5 * i as F + rng_uniform(&mut seed, 0.0, 0.3),
164                ]
165            })
166            .collect();
167        assert_fd_grad(&r, &coords);
168    }
169
170    #[test]
171    fn point_gradient_matches_finite_difference() {
172        let r = ExponentialPoint::new([0.0, 0.0, 0.0], 1000.0, 4.0);
173        let mut seed = 0x5151_7777u64;
174        let coords: Vec<[F; 3]> = (0..20)
175            .map(|i| {
176                let rad = 2.0 + 1.5 * i as F + rng_uniform(&mut seed, 0.0, 0.3);
177                let t = rng_uniform(&mut seed, 0.3, 1.2);
178                [
179                    rad * t.cos(),
180                    rad * t.sin(),
181                    rng_uniform(&mut seed, -3.0, 3.0),
182                ]
183            })
184            .collect();
185        assert_fd_grad(&r, &coords);
186    }
187
188    #[test]
189    fn penalty_zero_on_target_quantiles() {
190        let r = ExponentialPlane::new([0.0, 0.0, 1.0], 0.0, 1000.0, 4.0);
191        let n = 50;
192        let coords: Vec<[F; 3]> = (0..n)
193            .map(|k| [0.0, 0.0, exponential_quantile((k as F + 0.5) / n as F, 4.0)])
194            .collect();
195        assert!(r.f(&coords, 1.0, 1.0) < 1e-6);
196    }
197}