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