Skip to main content

sci_form/esp/
esp.rs

1//! Electrostatic potential (ESP) on 3D grids + Gaussian .cube format I/O.
2//!
3//! ESP at point r:
4//!   Φ(r) = Σ_A Z_A / |r - R_A|  −  Σ_{μν} P_{μν} ∫ φ_μ(r') / |r - r'| φ_ν(r') dr'
5//!
6//! The full two-electron integral is expensive. For EHT-level ESP we use the
7//! Mulliken-charge approximation:
8//!   Φ(r) ≈ Σ_A q_A / |r - R_A|
9//!
10//! where q_A is the full nuclear charge (not just valence) minus the
11//! Mulliken electron population.
12//!
13//! For a more refined ESP: we can sum nuclear + point-charge electron contributions:
14//!   Φ(r) = Σ_A Z_nuclear / |r - R_A|  −  Σ_{μ} n_μ  Σ_g c_g ∫ g(r') / |r-r'| dr'
15//!
16//! We use the Mulliken approach for speed, which is standard for semi-empirical methods.
17
18use serde::{Deserialize, Serialize};
19use std::io::{BufRead, Write};
20
21/// Bohr ↔ Ångström conversion.
22const BOHR_TO_ANG: f64 = 0.529177;
23const ANG_TO_BOHR: f64 = 1.0 / BOHR_TO_ANG;
24
25/// ESP grid result.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EspGrid {
28    /// Origin in Ångström.
29    pub origin: [f64; 3],
30    /// Grid spacing in Ångström.
31    pub spacing: f64,
32    /// Dimensions [nx, ny, nz].
33    pub dims: [usize; 3],
34    /// ESP values in atomic units (Hartree/e).
35    pub values: Vec<f64>,
36}
37
38/// Compute ESP on a 3D grid using Mulliken-charge approximation.
39///
40/// - `elements`: atomic numbers
41/// - `positions`: Ångström
42/// - `mulliken_charges`: per-atom partial charges from population analysis
43/// - `spacing`: grid spacing in Ångström
44/// - `padding`: padding around molecule in Ångström
45pub fn compute_esp_grid(
46    elements: &[u8],
47    positions: &[[f64; 3]],
48    mulliken_charges: &[f64],
49    spacing: f64,
50    padding: f64,
51) -> EspGrid {
52    let n_atoms = elements.len();
53
54    let spacing = if spacing <= 0.0 { 0.5 } else { spacing };
55    let padding = padding.max(0.0);
56
57    // Compute bounding box
58    let mut min = [f64::MAX; 3];
59    let mut max = [f64::MIN; 3];
60    for pos in positions {
61        for k in 0..3 {
62            min[k] = min[k].min(pos[k]);
63            max[k] = max[k].max(pos[k]);
64        }
65    }
66
67    let origin = [min[0] - padding, min[1] - padding, min[2] - padding];
68    let dims = [
69        ((max[0] - min[0] + 2.0 * padding) / spacing).ceil() as usize + 1,
70        ((max[1] - min[1] + 2.0 * padding) / spacing).ceil() as usize + 1,
71        ((max[2] - min[2] + 2.0 * padding) / spacing).ceil() as usize + 1,
72    ];
73
74    let total = dims[0] * dims[1] * dims[2];
75    let mut values = vec![0.0f64; total];
76
77    // For each grid point, compute ESP = Σ_A q_eff(A) / |r - R_A|
78    // q_eff = nuclear_charge - core_electrons - mulliken_valence_population
79    // But simpler: use full nuclear charge + Mulliken partial charge concept
80    // ESP = Σ_A [ Z_A / |r-R_A| - electron_pop_A / |r-R_A| ]
81    // = Σ_A (Z_A - electron_pop_A) / |r-R_A|
82    // = Σ_A q_effective / |r-R_A|
83    //
84    // Where q_effective = Z_nuclear - (Z_valence - q_mulliken) - core_electrons
85    //                   = Z_nuclear - core_electrons - Z_valence + q_mulliken
86    //                   = 0 + q_mulliken (for neutral atoms, Z = core + valence)
87    //
88    // So ESP(r) = Σ_A q_mulliken(A) / |r - R_A|  (in appropriate units)
89    //
90    // Convert to atomic units: positions Å→bohr, charges in e, ESP in Hartree/e
91
92    for ix in 0..dims[0] {
93        for iy in 0..dims[1] {
94            for iz in 0..dims[2] {
95                let rx = origin[0] + ix as f64 * spacing;
96                let ry = origin[1] + iy as f64 * spacing;
97                let rz = origin[2] + iz as f64 * spacing;
98
99                let mut phi = 0.0;
100                for a in 0..n_atoms {
101                    let dx = rx - positions[a][0];
102                    let dy = ry - positions[a][1];
103                    let dz = rz - positions[a][2];
104                    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
105
106                    // Clamp distance to avoid singularity at nucleus.
107                    // Using 0.1 Å minimum prevents discontinuities while
108                    // keeping the ESP smooth near nuclei.
109                    let dist_clamped = dist.max(0.1);
110
111                    // ESP contribution: Φ(r) = Σ_A q_mulliken(A) / |r - R_A|
112                    // (in atomic units: distance converted to bohr)
113                    phi += mulliken_charges[a] / (dist_clamped * ANG_TO_BOHR);
114                }
115
116                let idx = ix * dims[1] * dims[2] + iy * dims[2] + iz;
117                values[idx] = phi;
118            }
119        }
120    }
121
122    EspGrid {
123        origin,
124        spacing,
125        dims,
126        values,
127    }
128}
129
130/// Compute ESP on a 3D grid using rayon parallelism.
131///
132/// Same as `compute_esp_grid` but evaluates grid points in parallel.
133#[cfg(feature = "parallel")]
134pub fn compute_esp_grid_parallel(
135    elements: &[u8],
136    positions: &[[f64; 3]],
137    mulliken_charges: &[f64],
138    spacing: f64,
139    padding: f64,
140) -> EspGrid {
141    use rayon::prelude::*;
142
143    let _n_atoms = elements.len();
144    let spacing = if spacing <= 0.0 { 0.5 } else { spacing };
145    let padding = padding.max(0.0);
146
147    let mut min = [f64::MAX; 3];
148    let mut max = [f64::MIN; 3];
149    for pos in positions {
150        for k in 0..3 {
151            min[k] = min[k].min(pos[k]);
152            max[k] = max[k].max(pos[k]);
153        }
154    }
155
156    let origin = [min[0] - padding, min[1] - padding, min[2] - padding];
157    let dims = [
158        ((max[0] - min[0] + 2.0 * padding) / spacing).ceil() as usize + 1,
159        ((max[1] - min[1] + 2.0 * padding) / spacing).ceil() as usize + 1,
160        ((max[2] - min[2] + 2.0 * padding) / spacing).ceil() as usize + 1,
161    ];
162
163    let total = dims[0] * dims[1] * dims[2];
164    let ny = dims[1];
165    let nz = dims[2];
166    let positions_clone = positions.to_vec();
167    let charges_clone = mulliken_charges.to_vec();
168
169    let values: Vec<f64> = (0..total)
170        .into_par_iter()
171        .map(|flat_idx| {
172            let ix = flat_idx / (ny * nz);
173            let iy = (flat_idx / nz) % ny;
174            let iz = flat_idx % nz;
175
176            let rx = origin[0] + ix as f64 * spacing;
177            let ry = origin[1] + iy as f64 * spacing;
178            let rz = origin[2] + iz as f64 * spacing;
179
180            let mut phi = 0.0;
181            for (a, pos) in positions_clone.iter().enumerate() {
182                let dx = rx - pos[0];
183                let dy = ry - pos[1];
184                let dz = rz - pos[2];
185                let dist = (dx * dx + dy * dy + dz * dz).sqrt();
186                let dist_clamped = dist.max(0.1);
187                phi += charges_clone[a] / (dist_clamped * ANG_TO_BOHR);
188            }
189            phi
190        })
191        .collect();
192
193    EspGrid {
194        origin,
195        spacing,
196        dims,
197        values,
198    }
199}
200
201/// Map an ESP value to an RGB color.
202///
203/// Red = negative potential (electron-rich), Blue = positive (electron-poor),
204/// White = neutral.  The value is clamped to `[-range, +range]` before mapping.
205///
206/// Returns `[r, g, b]` where each component is in `[0, 255]`.
207pub fn esp_color_map(value: f64, range: f64) -> [u8; 3] {
208    let clamped = value.max(-range).min(range);
209    let t = clamped / range; // -1..+1
210
211    if t < 0.0 {
212        // Negative → red (through white)
213        let f = (-t).min(1.0);
214        let white = ((1.0 - f) * 255.0) as u8;
215        [255, white, white]
216    } else {
217        // Positive → blue (through white)
218        let f = t.min(1.0);
219        let white = ((1.0 - f) * 255.0) as u8;
220        [white, white, 255]
221    }
222}
223
224/// Map an entire ESP grid to RGB colors.
225///
226/// Returns a flat `Vec<u8>` of length `3 * values.len()` in `[r,g,b, r,g,b, ...]` order.
227/// `range` controls the saturation: values beyond ±range map to full red/blue.
228pub fn esp_grid_to_colors(grid: &EspGrid, range: f64) -> Vec<u8> {
229    let mut colors = Vec::with_capacity(grid.values.len() * 3);
230    for &val in &grid.values {
231        let [r, g, b] = esp_color_map(val, range);
232        colors.push(r);
233        colors.push(g);
234        colors.push(b);
235    }
236    colors
237}
238
239/// Gaussian .cube file (for I/O).
240#[derive(Debug, Clone)]
241pub struct CubeFile {
242    pub comment1: String,
243    pub comment2: String,
244    pub elements: Vec<u8>,
245    pub positions: Vec<[f64; 3]>,
246    pub origin: [f64; 3],
247    pub dims: [usize; 3],
248    pub axes: [[f64; 3]; 3],
249    pub values: Vec<f64>,
250}
251
252/// Export ESP grid as a Gaussian .cube file.
253pub fn export_cube<W: Write>(
254    writer: &mut W,
255    elements: &[u8],
256    positions: &[[f64; 3]],
257    grid: &EspGrid,
258) -> std::io::Result<()> {
259    let n_atoms = elements.len();
260    writeln!(writer, " ESP generated by sci-form")?;
261    writeln!(writer, " Mulliken-charge approximation")?;
262
263    // Origin in bohr
264    let o = [
265        grid.origin[0] * ANG_TO_BOHR,
266        grid.origin[1] * ANG_TO_BOHR,
267        grid.origin[2] * ANG_TO_BOHR,
268    ];
269    writeln!(
270        writer,
271        "{:5} {:12.6} {:12.6} {:12.6}",
272        n_atoms as i32, o[0], o[1], o[2]
273    )?;
274
275    // Axis vectors (orthogonal, spacing in bohr)
276    let sp = grid.spacing * ANG_TO_BOHR;
277    writeln!(
278        writer,
279        "{:5} {:12.6} {:12.6} {:12.6}",
280        grid.dims[0], sp, 0.0, 0.0
281    )?;
282    writeln!(
283        writer,
284        "{:5} {:12.6} {:12.6} {:12.6}",
285        grid.dims[1], 0.0, sp, 0.0
286    )?;
287    writeln!(
288        writer,
289        "{:5} {:12.6} {:12.6} {:12.6}",
290        grid.dims[2], 0.0, 0.0, sp
291    )?;
292
293    // Atom lines
294    for i in 0..n_atoms {
295        let z = elements[i] as i32;
296        writeln!(
297            writer,
298            "{:5} {:12.6} {:12.6} {:12.6} {:12.6}",
299            z,
300            z as f64,
301            positions[i][0] * ANG_TO_BOHR,
302            positions[i][1] * ANG_TO_BOHR,
303            positions[i][2] * ANG_TO_BOHR
304        )?;
305    }
306
307    // Volume data (6 values per line, x varies slowest, z fastest)
308    let mut count = 0;
309    for val in &grid.values {
310        write!(writer, " {:13.8E}", val)?;
311        count += 1;
312        if count % 6 == 0 {
313            writeln!(writer)?;
314        }
315    }
316    if count % 6 != 0 {
317        writeln!(writer)?;
318    }
319
320    Ok(())
321}
322
323/// Read a Gaussian .cube file.
324pub fn read_cube<R: BufRead>(reader: &mut R) -> Result<CubeFile, String> {
325    let mut lines = Vec::new();
326    let mut buf = String::new();
327    loop {
328        buf.clear();
329        match reader.read_line(&mut buf) {
330            Ok(0) => break,
331            Ok(_) => lines.push(buf.trim_end().to_string()),
332            Err(e) => return Err(format!("Read error: {}", e)),
333        }
334    }
335
336    if lines.len() < 6 {
337        return Err("Cube file too short".to_string());
338    }
339
340    let comment1 = lines[0].clone();
341    let comment2 = lines[1].clone();
342
343    // Line 3: n_atoms origin_x origin_y origin_z
344    let parts: Vec<f64> = lines[2]
345        .split_whitespace()
346        .filter_map(|s| s.parse().ok())
347        .collect();
348    let n_atoms = parts[0] as usize;
349    let origin = [parts[1], parts[2], parts[3]]; // bohr
350
351    // Lines 4-6: nx vx_x vx_y vx_z (axis vectors)
352    let mut dims = [0usize; 3];
353    let mut axes = [[0.0f64; 3]; 3];
354    for k in 0..3 {
355        let p: Vec<f64> = lines[3 + k]
356            .split_whitespace()
357            .filter_map(|s| s.parse().ok())
358            .collect();
359        dims[k] = p[0] as usize;
360        axes[k] = [p[1], p[2], p[3]]; // bohr
361    }
362
363    // Atom lines
364    let mut elements = Vec::new();
365    let mut positions = Vec::new();
366    for i in 0..n_atoms {
367        let p: Vec<f64> = lines[6 + i]
368            .split_whitespace()
369            .filter_map(|s| s.parse().ok())
370            .collect();
371        elements.push(p[0] as u8);
372        positions.push([p[2], p[3], p[4]]); // bohr
373    }
374
375    // Volume data
376    let data_start = 6 + n_atoms;
377    let mut values = Vec::new();
378    for line in &lines[data_start..] {
379        for token in line.split_whitespace() {
380            if let Ok(v) = token.parse::<f64>() {
381                values.push(v);
382            }
383        }
384    }
385
386    let expected = dims[0] * dims[1] * dims[2];
387    if values.len() != expected {
388        return Err(format!(
389            "Expected {} values, got {}",
390            expected,
391            values.len()
392        ));
393    }
394
395    Ok(CubeFile {
396        comment1,
397        comment2,
398        elements,
399        positions,
400        origin,
401        dims,
402        axes,
403        values,
404    })
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::eht::solve_eht;
411    use crate::population::compute_population;
412
413    fn water_molecule() -> (Vec<u8>, Vec<[f64; 3]>) {
414        (
415            vec![8, 1, 1],
416            vec![[0.0, 0.0, 0.0], [0.757, 0.586, 0.0], [-0.757, 0.586, 0.0]],
417        )
418    }
419
420    #[test]
421    fn test_esp_grid_dimensions() {
422        let (elems, pos) = water_molecule();
423        let result = solve_eht(&elems, &pos, None).unwrap();
424        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
425
426        let grid = compute_esp_grid(&elems, &pos, &pop.mulliken_charges, 0.5, 2.0);
427
428        assert_eq!(
429            grid.values.len(),
430            grid.dims[0] * grid.dims[1] * grid.dims[2]
431        );
432        assert!(grid.dims[0] > 0 && grid.dims[1] > 0 && grid.dims[2] > 0);
433    }
434
435    #[test]
436    fn test_esp_no_nan() {
437        let (elems, pos) = water_molecule();
438        let result = solve_eht(&elems, &pos, None).unwrap();
439        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
440
441        let grid = compute_esp_grid(&elems, &pos, &pop.mulliken_charges, 0.5, 2.0);
442
443        assert!(
444            grid.values.iter().all(|&v| !v.is_nan()),
445            "ESP grid should have no NaN values"
446        );
447    }
448
449    #[test]
450    fn test_esp_sign_near_oxygen() {
451        let (elems, pos) = water_molecule();
452        let result = solve_eht(&elems, &pos, None).unwrap();
453        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
454
455        // ESP near O (negative charge) should be negative
456        // Place test point near oxygen (slightly offset)
457        let charges = &pop.mulliken_charges;
458        let test_point = [0.5, 0.0, 0.0]; // near O
459        let mut phi = 0.0;
460        for a in 0..3 {
461            let dx = test_point[0] - pos[a][0];
462            let dy = test_point[1] - pos[a][1];
463            let dz = test_point[2] - pos[a][2];
464            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
465            if dist > 0.01 {
466                phi += charges[a] / (dist * ANG_TO_BOHR);
467            }
468        }
469        assert!(phi < 0.0, "ESP near oxygen should be negative, got {}", phi);
470    }
471
472    #[test]
473    fn test_cube_roundtrip() {
474        let (elems, pos) = water_molecule();
475        let result = solve_eht(&elems, &pos, None).unwrap();
476        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
477        let grid = compute_esp_grid(&elems, &pos, &pop.mulliken_charges, 0.5, 2.0);
478
479        // Export
480        let mut buf = Vec::new();
481        export_cube(&mut buf, &elems, &pos, &grid).unwrap();
482
483        // Read back
484        let mut reader = std::io::BufReader::new(&buf[..]);
485        let cube = read_cube(&mut reader).unwrap();
486
487        assert_eq!(cube.dims, grid.dims);
488        assert_eq!(cube.values.len(), grid.values.len());
489        // Values should match within formatting precision
490        for (a, b) in cube.values.iter().zip(grid.values.iter()) {
491            assert!(
492                (a - b).abs() < 1e-3,
493                "Cube roundtrip mismatch: {} vs {}",
494                a,
495                b
496            );
497        }
498    }
499
500    #[test]
501    fn test_esp_far_from_molecule_near_zero() {
502        let elems = vec![1u8, 1];
503        let pos = vec![[0.0, 0.0, 0.0], [0.74, 0.0, 0.0]];
504        let result = solve_eht(&elems, &pos, None).unwrap();
505        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
506
507        // For symmetric H₂, charges are ~0 → ESP should be ~0 everywhere
508        let grid = compute_esp_grid(&elems, &pos, &pop.mulliken_charges, 1.0, 5.0);
509
510        let max_abs: f64 = grid.values.iter().map(|v| v.abs()).fold(0.0, f64::max);
511        assert!(
512            max_abs < 1.0,
513            "ESP for neutral symmetric H₂ should be very small, max={}",
514            max_abs
515        );
516    }
517
518    #[test]
519    fn test_esp_color_map_negative() {
520        let [r, g, b] = esp_color_map(-1.0, 1.0);
521        assert_eq!(r, 255);
522        assert_eq!(g, 0);
523        assert_eq!(b, 0);
524    }
525
526    #[test]
527    fn test_esp_color_map_positive() {
528        let [r, g, b] = esp_color_map(1.0, 1.0);
529        assert_eq!(r, 0);
530        assert_eq!(g, 0);
531        assert_eq!(b, 255);
532    }
533
534    #[test]
535    fn test_esp_color_map_zero() {
536        let [r, g, b] = esp_color_map(0.0, 1.0);
537        assert_eq!(r, 255);
538        assert_eq!(g, 255);
539        assert_eq!(b, 255);
540    }
541
542    #[test]
543    fn test_esp_grid_to_colors_length() {
544        let (elems, pos) = water_molecule();
545        let result = solve_eht(&elems, &pos, None).unwrap();
546        let pop = compute_population(&elems, &pos, &result.coefficients, result.n_electrons);
547        let grid = compute_esp_grid(&elems, &pos, &pop.mulliken_charges, 1.0, 2.0);
548
549        let colors = esp_grid_to_colors(&grid, 0.1);
550        assert_eq!(colors.len(), grid.values.len() * 3);
551    }
552}