Skip to main content

sci_form/eht/
volume.rs

1//! 3D volumetric mapping of molecular orbitals.
2//!
3//! Evaluates Ψ_i(x,y,z) = Σ_μ C_μi φ_μ(x,y,z) on a regular 3D grid.
4
5use serde::{Deserialize, Serialize};
6use std::f64::consts::PI;
7
8use super::basis::AtomicOrbital;
9
10/// A 3D regular grid holding scalar field values.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct VolumetricGrid {
13    /// Origin corner (x_min, y_min, z_min) in Ångström.
14    pub origin: [f64; 3],
15    /// Grid spacing in Ångström.
16    pub spacing: f64,
17    /// Number of grid points in each dimension [nx, ny, nz].
18    pub dims: [usize; 3],
19    /// Flat array of values in row-major order: z varies fastest.
20    pub values: Vec<f32>,
21}
22
23impl VolumetricGrid {
24    /// Total number of grid points.
25    pub fn num_points(&self) -> usize {
26        self.dims[0] * self.dims[1] * self.dims[2]
27    }
28
29    /// Index for grid point (ix, iy, iz).
30    pub fn index(&self, ix: usize, iy: usize, iz: usize) -> usize {
31        ix * self.dims[1] * self.dims[2] + iy * self.dims[2] + iz
32    }
33
34    /// Get the Cartesian position (in Ångström) of grid point (ix, iy, iz).
35    pub fn point_position(&self, ix: usize, iy: usize, iz: usize) -> [f64; 3] {
36        [
37            self.origin[0] + ix as f64 * self.spacing,
38            self.origin[1] + iy as f64 * self.spacing,
39            self.origin[2] + iz as f64 * self.spacing,
40        ]
41    }
42}
43
44/// Build a bounding box around the molecular geometry with padding.
45///
46/// Returns (origin, dims) for the given spacing.
47pub fn compute_grid_extents(
48    positions: &[[f64; 3]],
49    padding: f64,
50    spacing: f64,
51) -> ([f64; 3], [usize; 3]) {
52    let mut min = [f64::MAX; 3];
53    let mut max = [f64::MIN; 3];
54    for pos in positions {
55        for d in 0..3 {
56            min[d] = min[d].min(pos[d]);
57            max[d] = max[d].max(pos[d]);
58        }
59    }
60
61    let origin = [min[0] - padding, min[1] - padding, min[2] - padding];
62    let dims = [
63        ((max[0] - min[0] + 2.0 * padding) / spacing).ceil() as usize + 1,
64        ((max[1] - min[1] + 2.0 * padding) / spacing).ceil() as usize + 1,
65        ((max[2] - min[2] + 2.0 * padding) / spacing).ceil() as usize + 1,
66    ];
67
68    (origin, dims)
69}
70
71/// Evaluate a single atomic orbital φ_μ at a point in space.
72///
73/// Point is in Ångström; the orbital center is in bohr, so convert here.
74fn evaluate_ao_at_point(orb: &AtomicOrbital, point_ang: &[f64; 3]) -> f64 {
75    let ang_to_bohr = 1.0 / 0.529177249;
76    let px = point_ang[0] * ang_to_bohr - orb.center[0];
77    let py = point_ang[1] * ang_to_bohr - orb.center[1];
78    let pz = point_ang[2] * ang_to_bohr - orb.center[2];
79    let r2 = px * px + py * py + pz * pz;
80
81    let mut val = 0.0;
82    for g in &orb.gaussians {
83        let exp_val = (-g.alpha * r2).exp();
84        match (orb.l, orb.m) {
85            (0, _) => {
86                // s-type
87                let norm = (2.0 * g.alpha / PI).powf(0.75);
88                val += g.coeff * norm * exp_val;
89            }
90            (1, -1) => {
91                // px
92                let norm = (128.0 * g.alpha.powi(5) / (PI * PI * PI)).powf(0.25);
93                val += g.coeff * norm * px * exp_val;
94            }
95            (1, 0) => {
96                // py
97                let norm = (128.0 * g.alpha.powi(5) / (PI * PI * PI)).powf(0.25);
98                val += g.coeff * norm * py * exp_val;
99            }
100            (1, 1) => {
101                // pz
102                let norm = (128.0 * g.alpha.powi(5) / (PI * PI * PI)).powf(0.25);
103                val += g.coeff * norm * pz * exp_val;
104            }
105            _ => {}
106        }
107    }
108
109    val
110}
111
112/// Evaluate molecular orbital `mo_index` on a 3D grid.
113///
114/// Ψ_i(r) = Σ_μ C_μi φ_μ(r)
115///
116/// - `basis`: the molecular AO basis set
117/// - `coefficients`: C matrix (row=AO, col=MO) as flat nested Vecs
118/// - `mo_index`: which molecular orbital to evaluate
119/// - `positions`: atom positions in Ångström
120/// - `spacing`: grid spacing in Ångström
121/// - `padding`: padding around the bounding box in Ångström
122pub fn evaluate_orbital_on_grid(
123    basis: &[AtomicOrbital],
124    coefficients: &[Vec<f64>],
125    mo_index: usize,
126    positions: &[[f64; 3]],
127    spacing: f64,
128    padding: f64,
129) -> VolumetricGrid {
130    let (origin, dims) = compute_grid_extents(positions, padding, spacing);
131    let total = dims[0] * dims[1] * dims[2];
132    let mut values = vec![0.0f32; total];
133
134    // Pre-extract MO coefficients for this orbital
135    let c_mu: Vec<f64> = (0..basis.len())
136        .map(|mu| coefficients[mu][mo_index])
137        .collect();
138
139    for ix in 0..dims[0] {
140        for iy in 0..dims[1] {
141            for iz in 0..dims[2] {
142                let point = [
143                    origin[0] + ix as f64 * spacing,
144                    origin[1] + iy as f64 * spacing,
145                    origin[2] + iz as f64 * spacing,
146                ];
147
148                let mut psi = 0.0f64;
149                for (mu, orb) in basis.iter().enumerate() {
150                    let phi = evaluate_ao_at_point(orb, &point);
151                    psi += c_mu[mu] * phi;
152                }
153
154                let idx = ix * dims[1] * dims[2] + iy * dims[2] + iz;
155                values[idx] = psi as f32;
156            }
157        }
158    }
159
160    VolumetricGrid {
161        origin,
162        spacing,
163        dims,
164        values,
165    }
166}
167
168/// Evaluate molecular orbital on a 3D grid using parallel iteration (rayon).
169#[cfg(feature = "parallel")]
170pub fn evaluate_orbital_on_grid_parallel(
171    basis: &[AtomicOrbital],
172    coefficients: &[Vec<f64>],
173    mo_index: usize,
174    positions: &[[f64; 3]],
175    spacing: f64,
176    padding: f64,
177) -> VolumetricGrid {
178    use rayon::prelude::*;
179
180    let (origin, dims) = compute_grid_extents(positions, padding, spacing);
181    let total = dims[0] * dims[1] * dims[2];
182    let c_mu: Vec<f64> = (0..basis.len())
183        .map(|mu| coefficients[mu][mo_index])
184        .collect();
185    let ny = dims[1];
186    let nz = dims[2];
187
188    let values: Vec<f32> = (0..total)
189        .into_par_iter()
190        .map(|flat_idx| {
191            let ix = flat_idx / (ny * nz);
192            let iy = (flat_idx / nz) % ny;
193            let iz = flat_idx % nz;
194
195            let point = [
196                origin[0] + ix as f64 * spacing,
197                origin[1] + iy as f64 * spacing,
198                origin[2] + iz as f64 * spacing,
199            ];
200
201            let mut psi = 0.0f64;
202            for (mu, orb) in basis.iter().enumerate() {
203                let phi = evaluate_ao_at_point(orb, &point);
204                psi += c_mu[mu] * phi;
205            }
206
207            psi as f32
208        })
209        .collect();
210
211    VolumetricGrid {
212        origin,
213        spacing,
214        dims,
215        values,
216    }
217}
218
219/// A single z-slab of volumetric data, produced by chunked evaluation.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct VolumeSlab {
222    /// The x-index of this slab.
223    pub ix: usize,
224    /// Grid values for this slab: ny * nz entries (y varies, z fastest).
225    pub values: Vec<f32>,
226}
227
228/// Evaluate a molecular orbital on a 3D grid in x-slab chunks.
229///
230/// Instead of allocating one large `Vec<f32>` for the entire grid, this
231/// returns an iterator that yields one x-slab at a time.  Each slab has
232/// `dims[1] * dims[2]` values, so the peak memory is proportional to a
233/// single slice instead of the full volume.
234pub fn evaluate_orbital_on_grid_chunked(
235    basis: &[AtomicOrbital],
236    coefficients: &[Vec<f64>],
237    mo_index: usize,
238    positions: &[[f64; 3]],
239    spacing: f64,
240    padding: f64,
241) -> (VolumetricGrid, Vec<VolumeSlab>) {
242    let (origin, dims) = compute_grid_extents(positions, padding, spacing);
243    let c_mu: Vec<f64> = (0..basis.len())
244        .map(|mu| coefficients[mu][mo_index])
245        .collect();
246
247    let mut slabs = Vec::with_capacity(dims[0]);
248    let mut all_values = Vec::with_capacity(dims[0] * dims[1] * dims[2]);
249
250    for ix in 0..dims[0] {
251        let slab_size = dims[1] * dims[2];
252        let mut slab_values = Vec::with_capacity(slab_size);
253
254        for iy in 0..dims[1] {
255            for iz in 0..dims[2] {
256                let point = [
257                    origin[0] + ix as f64 * spacing,
258                    origin[1] + iy as f64 * spacing,
259                    origin[2] + iz as f64 * spacing,
260                ];
261
262                let mut psi = 0.0f64;
263                for (mu, orb) in basis.iter().enumerate() {
264                    let phi = evaluate_ao_at_point(orb, &point);
265                    psi += c_mu[mu] * phi;
266                }
267
268                slab_values.push(psi as f32);
269            }
270        }
271
272        all_values.extend_from_slice(&slab_values);
273        slabs.push(VolumeSlab {
274            ix,
275            values: slab_values,
276        });
277    }
278
279    let grid = VolumetricGrid {
280        origin,
281        spacing,
282        dims,
283        values: all_values,
284    };
285
286    (grid, slabs)
287}
288
289/// Evaluate the electron density |Ψ_i|² on a grid.
290pub fn evaluate_density_on_grid(
291    basis: &[AtomicOrbital],
292    coefficients: &[Vec<f64>],
293    mo_index: usize,
294    positions: &[[f64; 3]],
295    spacing: f64,
296    padding: f64,
297) -> VolumetricGrid {
298    let mut grid =
299        evaluate_orbital_on_grid(basis, coefficients, mo_index, positions, spacing, padding);
300    for v in &mut grid.values {
301        *v = (*v) * (*v);
302    }
303    grid
304}
305
306#[cfg(test)]
307mod tests {
308    use super::super::basis::build_basis;
309    use super::super::solver::solve_eht;
310    use super::*;
311
312    #[test]
313    fn test_grid_extents_h2() {
314        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
315        let (origin, dims) = compute_grid_extents(&positions, 4.0, 0.5);
316        assert!(origin[0] < -3.9);
317        assert!(dims[0] > 10);
318        assert!(dims[1] > 10);
319        assert!(dims[2] > 10);
320    }
321
322    #[test]
323    fn test_evaluate_orbital_h2_bonding() {
324        let elements = [1u8, 1];
325        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
326        let result = solve_eht(&elements, &positions, None).unwrap();
327        let basis = build_basis(&elements, &positions);
328
329        // Evaluate bonding orbital (MO 0) on coarse grid
330        let grid = evaluate_orbital_on_grid(
331            &basis,
332            &result.coefficients,
333            0, // bonding MO
334            &positions,
335            0.5, // 0.5 Å spacing
336            3.0, // 3 Å padding
337        );
338
339        assert!(grid.num_points() > 0);
340        // Bonding orbital should have nonzero values
341        let max_val = grid.values.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
342        assert!(
343            max_val > 0.01,
344            "max |Ψ_bond| = {}, expected > 0.01",
345            max_val
346        );
347    }
348
349    #[test]
350    fn test_evaluate_orbital_h2_antibonding() {
351        let elements = [1u8, 1];
352        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
353        let result = solve_eht(&elements, &positions, None).unwrap();
354        let basis = build_basis(&elements, &positions);
355
356        // Antibonding MO 1
357        let grid = evaluate_orbital_on_grid(&basis, &result.coefficients, 1, &positions, 0.5, 3.0);
358
359        let max_val = grid.values.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
360        assert!(
361            max_val > 0.01,
362            "antibonding orbital should have nonzero amplitude"
363        );
364
365        // Antibonding orbital should have a nodal plane — both positive and negative values
366        let has_positive = grid.values.iter().any(|&v| v > 0.01);
367        let has_negative = grid.values.iter().any(|&v| v < -0.01);
368        assert!(
369            has_positive && has_negative,
370            "antibonding MO should have sign change"
371        );
372    }
373
374    #[test]
375    fn test_density_nonnegative() {
376        let elements = [1u8, 1];
377        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
378        let result = solve_eht(&elements, &positions, None).unwrap();
379        let basis = build_basis(&elements, &positions);
380
381        let grid = evaluate_density_on_grid(&basis, &result.coefficients, 0, &positions, 0.5, 3.0);
382
383        for v in &grid.values {
384            assert!(*v >= 0.0, "Density should never be negative: {}", v);
385        }
386    }
387
388    #[test]
389    fn test_grid_dimensions_consistent() {
390        let positions = [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]];
391        let (_, dims) = compute_grid_extents(&positions, 2.0, 0.25);
392        // With 1Å range + 4Å padding (2 each side) = 5Å → 5/0.25 + 1 = 21
393        assert!(dims[0] == 21, "dims[0] = {}", dims[0]);
394    }
395
396    #[test]
397    fn test_chunked_matches_full() {
398        let elements = [1u8, 1];
399        let positions = [[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
400        let result = solve_eht(&elements, &positions, None).unwrap();
401        let basis = build_basis(&elements, &positions);
402
403        let full_grid =
404            evaluate_orbital_on_grid(&basis, &result.coefficients, 0, &positions, 0.5, 3.0);
405        let (chunked_grid, slabs) =
406            evaluate_orbital_on_grid_chunked(&basis, &result.coefficients, 0, &positions, 0.5, 3.0);
407
408        // Grid metadata must match
409        assert_eq!(full_grid.dims, chunked_grid.dims);
410        assert_eq!(full_grid.origin, chunked_grid.origin);
411        assert_eq!(slabs.len(), chunked_grid.dims[0]);
412
413        // Values must match exactly
414        assert_eq!(full_grid.values.len(), chunked_grid.values.len());
415        for (a, b) in full_grid.values.iter().zip(chunked_grid.values.iter()) {
416            assert!((a - b).abs() < 1e-10, "mismatch: {} vs {}", a, b);
417        }
418
419        // Each slab should have ny*nz entries
420        let slab_size = chunked_grid.dims[1] * chunked_grid.dims[2];
421        for slab in &slabs {
422            assert_eq!(slab.values.len(), slab_size);
423        }
424    }
425}