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 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 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 continue;
106 }
107
108 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#[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
199pub fn esp_color_map(value: f64, range: f64) -> [u8; 3] {
206 let clamped = value.max(-range).min(range);
207 let t = clamped / range; if t < 0.0 {
210 let f = (-t).min(1.0);
212 let white = ((1.0 - f) * 255.0) as u8;
213 [255, white, white]
214 } else {
215 let f = t.min(1.0);
217 let white = ((1.0 - f) * 255.0) as u8;
218 [white, white, 255]
219 }
220}
221
222pub 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#[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
250pub 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 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 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 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 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
321pub 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 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]]; 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]]; }
360
361 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]]); }
372
373 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 let charges = &pop.mulliken_charges;
456 let test_point = [0.5, 0.0, 0.0]; 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 let mut buf = Vec::new();
479 export_cube(&mut buf, &elems, &pos, &grid).unwrap();
480
481 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 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 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}