1use serde::{Deserialize, Serialize};
19use std::io::{BufRead, Write};
20
21const BOHR_TO_ANG: f64 = 0.529177;
23const ANG_TO_BOHR: f64 = 1.0 / BOHR_TO_ANG;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EspGrid {
28 pub origin: [f64; 3],
30 pub spacing: f64,
32 pub dims: [usize; 3],
34 pub values: Vec<f64>,
36}
37
38pub 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 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 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 let dist_clamped = dist.max(0.1);
110
111 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#[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
201pub fn esp_color_map(value: f64, range: f64) -> [u8; 3] {
208 let clamped = value.max(-range).min(range);
209 let t = clamped / range; if t < 0.0 {
212 let f = (-t).min(1.0);
214 let white = ((1.0 - f) * 255.0) as u8;
215 [255, white, white]
216 } else {
217 let f = t.min(1.0);
219 let white = ((1.0 - f) * 255.0) as u8;
220 [white, white, 255]
221 }
222}
223
224pub 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#[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
252pub 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 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 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 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 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
323pub 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 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]]; 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]]; }
362
363 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]]); }
374
375 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 let charges = &pop.mulliken_charges;
458 let test_point = [0.5, 0.0, 0.0]; 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 let mut buf = Vec::new();
481 export_cube(&mut buf, &elems, &pos, &grid).unwrap();
482
483 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 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 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}