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