Skip to main content

molpack/restraint/collective/
tabulated.rs

1//! Tabulated (arbitrary-prior) members of the distribution-matching family: the
2//! reaction coordinate ξ is driven to **any** target density supplied as a grid
3//! `{(ξᵢ, ρᵢ)}` — so a profile from theory or experiment (e.g. the exact
4//! Gouy–Chapman counter-ion profile) is packable directly. Gaussian and
5//! exponential are the parametric special cases of this general form.
6//!
7//! The quantile function `q(p) = F⁻¹(p)` is precomputed once from the grid: the
8//! cumulative integral `F(ξ) = ∫ρ / ∫ρ` (trapezoid, normalised to `[0, 1]`) is
9//! built on the grid points and inverted by linear interpolation. The shared
10//! Wasserstein [`engine`](super::engine) then matches it like any other target.
11
12use molrs::types::F;
13
14use super::Restraint;
15use super::geometry::{plane_match_f, plane_match_fg, point_match_f, point_match_fg};
16
17/// Precomputed inverse-CDF (quantile) sampler for a tabulated target density.
18#[derive(Debug, Clone)]
19struct Quantile {
20    /// Ascending grid of reaction-coordinate values.
21    xi: Vec<F>,
22    /// Normalised cumulative integral at each grid point (`cdf[0]=0`, `cdf[last]=1`).
23    cdf: Vec<F>,
24}
25
26impl Quantile {
27    /// Build from a density grid. `xs` must be strictly ascending with ≥ 2
28    /// points, `rho` non-negative with positive total mass.
29    ///
30    /// # Panics
31    /// On a too-short, non-ascending, negative, or zero-mass grid.
32    fn from_grid(xs: &[F], rho: &[F]) -> Self {
33        assert!(xs.len() >= 2, "tabulated grid needs at least 2 points");
34        assert_eq!(xs.len(), rho.len(), "tabulated xs/rho length mismatch");
35        assert!(
36            xs.windows(2).all(|w| w[1] > w[0]),
37            "tabulated grid xs must be strictly ascending"
38        );
39        assert!(rho.iter().all(|&r| r >= 0.0), "tabulated rho must be ≥ 0");
40
41        let n = xs.len();
42        let mut cdf = vec![0.0 as F; n];
43        for i in 1..n {
44            let dx = xs[i] - xs[i - 1];
45            cdf[i] = cdf[i - 1] + 0.5 * (rho[i] + rho[i - 1]) * dx;
46        }
47        let total = cdf[n - 1];
48        assert!(
49            total > 0.0,
50            "tabulated density must have positive total mass"
51        );
52        for c in cdf.iter_mut() {
53            *c /= total;
54        }
55        Self {
56            xi: xs.to_vec(),
57            cdf,
58        }
59    }
60
61    /// `q(p) = F⁻¹(p)` for `p ∈ (0, 1)` by linear interpolation on the CDF grid.
62    #[inline]
63    fn quantile(&self, p: F) -> F {
64        let n = self.cdf.len();
65        if p <= self.cdf[0] {
66            return self.xi[0];
67        }
68        if p >= self.cdf[n - 1] {
69            return self.xi[n - 1];
70        }
71        // First index whose CDF reaches `p` (CDF is non-decreasing).
72        let i = self.cdf.partition_point(|&c| c < p);
73        let (c0, c1) = (self.cdf[i - 1], self.cdf[i]);
74        let (x0, x1) = (self.xi[i - 1], self.xi[i]);
75        if c1 > c0 {
76            x0 + (p - c0) / (c1 - c0) * (x1 - x0)
77        } else {
78            x0
79        }
80    }
81}
82
83// ============================================================================
84// TabulatedPlane — arbitrary target density of the signed plane distance
85// ============================================================================
86
87/// Drive a species' signed distance to a plane, `ξ = x·n̂ − offset`, to an
88/// arbitrary target density supplied as a grid `{(ξᵢ, ρᵢ)}`.
89#[derive(Debug, Clone)]
90pub struct TabulatedPlane {
91    normal: [F; 3],
92    offset: F,
93    strength: F,
94    quant: Quantile,
95}
96
97impl TabulatedPlane {
98    /// - `normal` — plane normal (normalised internally; need not be unit).
99    /// - `offset` — plane offset so `ξ = x·n̂ − offset`.
100    /// - `strength` — overall penalty multiplier.
101    /// - `xs`, `rho` — target density grid (ξ strictly ascending, ρ ≥ 0).
102    ///
103    /// # Panics
104    /// If the normal is the zero vector or the grid is invalid.
105    pub fn new(normal: [F; 3], offset: F, strength: F, xs: &[F], rho: &[F]) -> Self {
106        let norm = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
107        assert!(norm > 0.0, "TabulatedPlane normal must be non-zero");
108        Self {
109            normal: [normal[0] / norm, normal[1] / norm, normal[2] / norm],
110            offset,
111            strength,
112            quant: Quantile::from_grid(xs, rho),
113        }
114    }
115}
116
117impl Restraint for TabulatedPlane {
118    fn f(&self, coords: &[[F; 3]], _scale: F, _scale2: F) -> F {
119        plane_match_f(coords, &self.normal, self.offset, self.strength, |p| {
120            self.quant.quantile(p)
121        })
122    }
123
124    fn fg(&self, coords: &[[F; 3]], _scale: F, _scale2: F, grads: &mut [[F; 3]]) -> F {
125        plane_match_fg(
126            coords,
127            &self.normal,
128            self.offset,
129            self.strength,
130            |p| self.quant.quantile(p),
131            grads,
132        )
133    }
134
135    fn name(&self) -> &'static str {
136        "TabulatedPlane"
137    }
138}
139
140// ============================================================================
141// TabulatedPoint — arbitrary target density of the distance to a point
142// ============================================================================
143
144/// Drive a species' distance to a centre, `ξ = ‖x − c‖`, to an arbitrary target
145/// density supplied as a grid `{(ξᵢ, ρᵢ)}` (a prescribed radial profile).
146#[derive(Debug, Clone)]
147pub struct TabulatedPoint {
148    center: [F; 3],
149    strength: F,
150    quant: Quantile,
151}
152
153impl TabulatedPoint {
154    /// - `center` — the point `c` distances are measured from.
155    /// - `strength` — overall penalty multiplier.
156    /// - `xs`, `rho` — target radial density grid (ξ = r strictly ascending, ρ ≥ 0).
157    ///
158    /// # Panics
159    /// If the grid is invalid.
160    pub fn new(center: [F; 3], strength: F, xs: &[F], rho: &[F]) -> Self {
161        Self {
162            center,
163            strength,
164            quant: Quantile::from_grid(xs, rho),
165        }
166    }
167}
168
169impl Restraint for TabulatedPoint {
170    fn f(&self, coords: &[[F; 3]], _scale: F, _scale2: F) -> F {
171        point_match_f(coords, &self.center, self.strength, |p| {
172            self.quant.quantile(p)
173        })
174    }
175
176    fn fg(&self, coords: &[[F; 3]], _scale: F, _scale2: F, grads: &mut [[F; 3]]) -> F {
177        point_match_fg(
178            coords,
179            &self.center,
180            self.strength,
181            |p| self.quant.quantile(p),
182            grads,
183        )
184    }
185
186    fn name(&self) -> &'static str {
187        "TabulatedPoint"
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::super::testutil::{assert_fd_grad, rng_uniform};
194    use super::*;
195
196    /// A fine grid sampling a Gaussian density; the tabulated quantile should
197    /// reproduce the closed-form Gaussian quantile to grid accuracy.
198    fn gaussian_grid(mu: F, sigma: F) -> (Vec<F>, Vec<F>) {
199        let lo = mu - 6.0 * sigma;
200        let hi = mu + 6.0 * sigma;
201        let n = 1200;
202        let xs: Vec<F> = (0..n)
203            .map(|i| lo + (hi - lo) * i as F / (n - 1) as F)
204            .collect();
205        let rho: Vec<F> = xs
206            .iter()
207            .map(|&x| (-0.5 * ((x - mu) / sigma).powi(2)).exp())
208            .collect();
209        (xs, rho)
210    }
211
212    #[test]
213    fn quantile_recovers_gaussian() {
214        use super::super::engine::probit;
215        let (xs, rho) = gaussian_grid(20.0, 5.0);
216        let q = Quantile::from_grid(&xs, &rho);
217        for &p in &[0.1, 0.25, 0.5, 0.75, 0.9] {
218            let want = 20.0 + 5.0 * probit(p);
219            assert!(
220                (q.quantile(p) - want).abs() < 0.05,
221                "tabulated q({p})={}, want {want}",
222                q.quantile(p)
223            );
224        }
225    }
226
227    #[test]
228    fn plane_gradient_matches_finite_difference() {
229        let (xs, rho) = gaussian_grid(20.0, 5.0);
230        let r = TabulatedPlane::new([0.0, 0.0, 1.0], 0.0, 1000.0, &xs, &rho);
231        let mut seed = 0x1357_2468u64;
232        let coords: Vec<[F; 3]> = (0..20)
233            .map(|i| {
234                [
235                    rng_uniform(&mut seed, 0.0, 10.0),
236                    rng_uniform(&mut seed, 0.0, 10.0),
237                    2.0 * i as F + rng_uniform(&mut seed, 0.0, 0.4),
238                ]
239            })
240            .collect();
241        assert_fd_grad(&r, &coords);
242    }
243
244    #[test]
245    fn point_gradient_matches_finite_difference() {
246        // A decaying radial target (exponential-like grid).
247        let n = 400;
248        let xs: Vec<F> = (0..n).map(|i| 0.05 * i as F).collect();
249        let rho: Vec<F> = xs.iter().map(|&x| (-x / 4.0).exp()).collect();
250        let r = TabulatedPoint::new([0.0, 0.0, 0.0], 1000.0, &xs, &rho);
251        let mut seed = 0x2468_1357u64;
252        let coords: Vec<[F; 3]> = (0..20)
253            .map(|i| {
254                let rad = 2.0 + 1.5 * i as F + rng_uniform(&mut seed, 0.0, 0.3);
255                let t = rng_uniform(&mut seed, 0.3, 1.2);
256                [
257                    rad * t.cos(),
258                    rad * t.sin(),
259                    rng_uniform(&mut seed, -3.0, 3.0),
260                ]
261            })
262            .collect();
263        assert_fd_grad(&r, &coords);
264    }
265
266    #[test]
267    fn penalty_zero_on_target_quantiles() {
268        let (xs, rho) = gaussian_grid(20.0, 5.0);
269        let r = TabulatedPlane::new([0.0, 0.0, 1.0], 0.0, 1000.0, &xs, &rho);
270        let n = 50;
271        let q = Quantile::from_grid(&xs, &rho);
272        let coords: Vec<[F; 3]> = (0..n)
273            .map(|k| [0.0, 0.0, q.quantile((k as F + 0.5) / n as F)])
274            .collect();
275        assert!(r.f(&coords, 1.0, 1.0) < 1e-6);
276    }
277}