Skip to main content

proof_engine/quantum/
hydrogen.rs

1use std::f64::consts::PI;
2use super::schrodinger::Complex;
3use glam::Vec2;
4
5/// Hydrogen atom energy levels: E_n = -13.6 / n^2 eV.
6pub fn hydrogen_energy(n: u32) -> f64 {
7    if n == 0 { return 0.0; }
8    -13.6 / (n as f64 * n as f64)
9}
10
11/// Associated Legendre polynomial P_l^m(x) via recurrence.
12/// Uses the convention without the Condon-Shortley phase (it's included in Y_l^m).
13pub fn associated_legendre(l: u32, m: i32, x: f64) -> f64 {
14    let m_abs = m.unsigned_abs();
15    if m_abs > l {
16        return 0.0;
17    }
18
19    // Start with P_m^m
20    let mut pmm = 1.0;
21    if m_abs > 0 {
22        let somx2 = ((1.0 - x) * (1.0 + x)).sqrt();
23        let mut fact = 1.0;
24        for _i in 0..m_abs {
25            pmm *= -fact * somx2;
26            fact += 2.0;
27        }
28    }
29
30    if l == m_abs {
31        if m < 0 {
32            return adjust_negative_m(l, m, pmm);
33        }
34        return pmm;
35    }
36
37    // P_{m+1}^m
38    let mut pmmp1 = x * (2 * m_abs + 1) as f64 * pmm;
39    if l == m_abs + 1 {
40        if m < 0 {
41            return adjust_negative_m(l, m, pmmp1);
42        }
43        return pmmp1;
44    }
45
46    // Recurrence for higher l
47    let mut pll = 0.0;
48    for ll in (m_abs + 2)..=l {
49        pll = (x * (2 * ll - 1) as f64 * pmmp1 - (ll + m_abs - 1) as f64 * pmm) / (ll - m_abs) as f64;
50        pmm = pmmp1;
51        pmmp1 = pll;
52    }
53
54    if m < 0 {
55        adjust_negative_m(l, m, pll)
56    } else {
57        pll
58    }
59}
60
61fn adjust_negative_m(l: u32, m: i32, plm: f64) -> f64 {
62    let m_abs = m.unsigned_abs();
63    let sign = if m_abs % 2 == 0 { 1.0 } else { -1.0 };
64    let num: f64 = (1..=(l - m_abs) as u64).map(|k| k as f64).product::<f64>().max(1.0);
65    let den: f64 = (1..=(l + m_abs) as u64).map(|k| k as f64).product::<f64>().max(1.0);
66    sign * (num / den) * plm
67}
68
69/// Spherical harmonic Y_l^m(theta, phi).
70pub fn spherical_harmonic(l: u32, m: i32, theta: f64, phi: f64) -> Complex {
71    let m_abs = m.unsigned_abs();
72    if m_abs > l {
73        return Complex::zero();
74    }
75
76    // Normalization factor
77    let num: f64 = (1..=(l - m_abs) as u64).map(|k| k as f64).product::<f64>().max(1.0);
78    let den: f64 = (1..=(l + m_abs) as u64).map(|k| k as f64).product::<f64>().max(1.0);
79    let norm = ((2 * l + 1) as f64 / (4.0 * PI) * num / den).sqrt();
80
81    let plm = associated_legendre(l, m.abs(), theta.cos());
82    let phase = Complex::from_polar(1.0, m as f64 * phi);
83
84    let cs_phase = if m > 0 && m % 2 != 0 { -1.0 } else { 1.0 };
85
86    if m >= 0 {
87        phase * (norm * plm * cs_phase)
88    } else {
89        let sign = if m_abs % 2 == 0 { 1.0 } else { -1.0 };
90        phase * (norm * plm * sign)
91    }
92}
93
94/// Factorial helper.
95fn factorial(n: u64) -> f64 {
96    (1..=n).map(|k| k as f64).product::<f64>().max(1.0)
97}
98
99/// Generalized Laguerre polynomial L_n^alpha(x) via recurrence.
100fn generalized_laguerre(n: u32, alpha: f64, x: f64) -> f64 {
101    if n == 0 {
102        return 1.0;
103    }
104    if n == 1 {
105        return 1.0 + alpha - x;
106    }
107    let mut l_prev = 1.0;
108    let mut l_curr = 1.0 + alpha - x;
109    for k in 1..n {
110        let kf = k as f64;
111        let l_next = ((2.0 * kf + 1.0 + alpha - x) * l_curr - (kf + alpha) * l_prev) / (kf + 1.0);
112        l_prev = l_curr;
113        l_curr = l_next;
114    }
115    l_curr
116}
117
118/// Radial wave function R_nl(r) for hydrogen atom.
119/// Uses Bohr radius a0 = 1 (atomic units).
120pub fn radial_wavefunction(n: u32, l: u32, r: f64) -> f64 {
121    if n == 0 || l >= n {
122        return 0.0;
123    }
124    let a0 = 1.0; // Bohr radius in atomic units
125    let nf = n as f64;
126    let rho = 2.0 * r / (nf * a0);
127
128    // Normalization
129    let num = factorial((n - l - 1) as u64);
130    let den = 2.0 * nf * factorial((n + l) as u64);
131    let norm = ((2.0 / (nf * a0)).powi(3) * num / den).sqrt();
132
133    let laguerre = generalized_laguerre(n - l - 1, 2.0 * l as f64 + 1.0, rho);
134
135    norm * (-rho / 2.0).exp() * rho.powi(l as i32) * laguerre
136}
137
138/// Full hydrogen orbital psi_nlm(r, theta, phi).
139pub fn hydrogen_orbital(n: u32, l: u32, m: i32, r: f64, theta: f64, phi: f64) -> Complex {
140    if l >= n || (m.unsigned_abs()) > l {
141        return Complex::zero();
142    }
143    let radial = radial_wavefunction(n, l, r);
144    let ylm = spherical_harmonic(l, m, theta, phi);
145    ylm * radial
146}
147
148/// Probability density |psi|^2 at a point.
149pub fn probability_density_3d(n: u32, l: u32, m: i32, r: f64, theta: f64, phi: f64) -> f64 {
150    hydrogen_orbital(n, l, m, r, theta, phi).norm_sq()
151}
152
153/// Radial probability density r^2 |R_nl(r)|^2 (probability of finding electron at radius r).
154pub fn radial_probability(n: u32, l: u32, r: f64) -> f64 {
155    let rnl = radial_wavefunction(n, l, r);
156    r * r * rnl * rnl
157}
158
159/// Human-readable orbital name.
160pub fn orbital_name(n: u32, l: u32, m: i32) -> String {
161    let l_char = match l {
162        0 => 's',
163        1 => 'p',
164        2 => 'd',
165        3 => 'f',
166        4 => 'g',
167        _ => '?',
168    };
169    format!("{}{}_{}", n, l_char, m)
170}
171
172/// Render a 2D slice of an orbital.
173pub fn render_orbital_slice(
174    n: u32,
175    l: u32,
176    m: i32,
177    plane: SlicePlane,
178    grid_size: usize,
179    extent: f64,
180) -> Vec<(Vec2, f64, Complex)> {
181    let mut result = Vec::with_capacity(grid_size * grid_size);
182    let step = 2.0 * extent / grid_size as f64;
183
184    for iy in 0..grid_size {
185        for ix in 0..grid_size {
186            let u = -extent + ix as f64 * step;
187            let v = -extent + iy as f64 * step;
188            let (r, theta, phi) = match plane {
189                SlicePlane::XY => {
190                    let r = (u * u + v * v).sqrt();
191                    let theta = PI / 2.0; // z=0 plane
192                    let phi = v.atan2(u);
193                    (r, theta, phi)
194                }
195                SlicePlane::XZ => {
196                    let r = (u * u + v * v).sqrt();
197                    let theta = if r > 1e-15 { (u / r).acos() } else { 0.0 };
198                    let phi = 0.0;
199                    (r, theta, phi)
200                }
201                SlicePlane::YZ => {
202                    let r = (u * u + v * v).sqrt();
203                    let theta = if r > 1e-15 { v.atan2(u) } else { 0.0 };
204                    let phi = PI / 2.0;
205                    (r, theta, phi)
206                }
207            };
208
209            let psi = hydrogen_orbital(n, l, m, r.max(1e-10), theta, phi);
210            let density = psi.norm_sq();
211            result.push((Vec2::new(u as f32, v as f32), density, psi));
212        }
213    }
214    result
215}
216
217/// Which plane to slice through for visualization.
218#[derive(Clone, Copy, Debug)]
219pub enum SlicePlane {
220    XY,
221    XZ,
222    YZ,
223}
224
225/// Renderer for hydrogen orbitals as 2D glyph brightness.
226pub struct HydrogenRenderer {
227    pub grid_size: usize,
228    pub extent: f64,
229}
230
231impl HydrogenRenderer {
232    pub fn new(grid_size: usize, extent: f64) -> Self {
233        Self { grid_size, extent }
234    }
235
236    pub fn render(&self, n: u32, l: u32, m: i32, plane: SlicePlane) -> Vec<Vec<(char, f64, f64, f64)>> {
237        let data = render_orbital_slice(n, l, m, plane, self.grid_size, self.extent);
238        let max_density = data.iter().map(|&(_, d, _)| d).fold(0.0_f64, f64::max);
239        let scale = if max_density > 1e-20 { 1.0 / max_density } else { 1.0 };
240
241        let mut grid = vec![vec![(' ', 0.0, 0.0, 0.0); self.grid_size]; self.grid_size];
242        for (idx, &(_, density, psi)) in data.iter().enumerate() {
243            let ix = idx % self.grid_size;
244            let iy = idx / self.grid_size;
245            let brightness = (density * scale).min(1.0);
246            let phase = psi.arg();
247            let (r, g, b) = super::wavefunction::PhaseColorMap::phase_to_rgb(phase);
248            let ch = if brightness > 0.8 {
249                '@'
250            } else if brightness > 0.5 {
251                '#'
252            } else if brightness > 0.2 {
253                '*'
254            } else if brightness > 0.05 {
255                '.'
256            } else {
257                ' '
258            };
259            grid[iy][ix] = (ch, r * brightness, g * brightness, b * brightness);
260        }
261        grid
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_hydrogen_energy_levels() {
271        assert!((hydrogen_energy(1) - (-13.6)).abs() < 0.01);
272        assert!((hydrogen_energy(2) - (-3.4)).abs() < 0.01);
273        assert!((hydrogen_energy(3) - (-13.6 / 9.0)).abs() < 0.01);
274    }
275
276    #[test]
277    fn test_ground_state_normalization() {
278        // Integrate r^2 |R_10(r)|^2 dr from 0 to large R
279        let dr = 0.01;
280        let r_max = 30.0;
281        let n_pts = (r_max / dr) as usize;
282        let integral: f64 = (1..n_pts)
283            .map(|i| {
284                let r = i as f64 * dr;
285                radial_probability(1, 0, r) * dr
286            })
287            .sum();
288        assert!(
289            (integral - 1.0).abs() < 0.05,
290            "Ground state radial norm: {}",
291            integral
292        );
293    }
294
295    #[test]
296    fn test_2s_normalization() {
297        let dr = 0.02;
298        let r_max = 50.0;
299        let n_pts = (r_max / dr) as usize;
300        let integral: f64 = (1..n_pts)
301            .map(|i| {
302                let r = i as f64 * dr;
303                radial_probability(2, 0, r) * dr
304            })
305            .sum();
306        assert!(
307            (integral - 1.0).abs() < 0.1,
308            "2s radial norm: {}",
309            integral
310        );
311    }
312
313    #[test]
314    fn test_radial_orthogonality() {
315        // <R_10|R_20> should be 0
316        let dr = 0.01;
317        let n_pts = 3000;
318        let integral: f64 = (1..n_pts)
319            .map(|i| {
320                let r = i as f64 * dr;
321                let r10 = radial_wavefunction(1, 0, r);
322                let r20 = radial_wavefunction(2, 0, r);
323                r * r * r10 * r20 * dr
324            })
325            .sum();
326        assert!(integral.abs() < 0.1, "<R_10|R_20> = {}", integral);
327    }
328
329    #[test]
330    fn test_spherical_harmonic_y00() {
331        // Y_0^0 = 1/(2*sqrt(pi))
332        let y00 = spherical_harmonic(0, 0, 0.5, 0.3);
333        let expected = 1.0 / (2.0 * PI.sqrt());
334        assert!((y00.re - expected).abs() < 1e-10);
335        assert!(y00.im.abs() < 1e-10);
336    }
337
338    #[test]
339    fn test_spherical_harmonic_normalization() {
340        // Integrate |Y_1^0|^2 sin(theta) dtheta dphi over sphere
341        let n_theta = 100;
342        let n_phi = 100;
343        let dtheta = PI / n_theta as f64;
344        let dphi = 2.0 * PI / n_phi as f64;
345        let integral: f64 = (0..n_theta)
346            .flat_map(|it| {
347                let theta = (it as f64 + 0.5) * dtheta;
348                (0..n_phi).map(move |ip| {
349                    let phi = (ip as f64 + 0.5) * dphi;
350                    spherical_harmonic(1, 0, theta, phi).norm_sq() * theta.sin() * dtheta * dphi
351                })
352            })
353            .sum();
354        assert!(
355            (integral - 1.0).abs() < 0.1,
356            "Y_1^0 norm: {}",
357            integral
358        );
359    }
360
361    #[test]
362    fn test_associated_legendre() {
363        // P_0^0(x) = 1
364        assert!((associated_legendre(0, 0, 0.5) - 1.0).abs() < 1e-10);
365        // P_1^0(x) = x
366        assert!((associated_legendre(1, 0, 0.5) - 0.5).abs() < 1e-10);
367        // P_1^1(x) = -sqrt(1-x^2)
368        let p11 = associated_legendre(1, 1, 0.5);
369        let expected = -(1.0 - 0.25_f64).sqrt();
370        assert!((p11 - expected).abs() < 1e-10, "P_1^1(0.5) = {}", p11);
371    }
372
373    #[test]
374    fn test_orbital_name() {
375        assert_eq!(orbital_name(1, 0, 0), "1s_0");
376        assert_eq!(orbital_name(2, 1, 1), "2p_1");
377        assert_eq!(orbital_name(3, 2, 0), "3d_0");
378    }
379
380    #[test]
381    fn test_most_probable_radius_1s() {
382        // For 1s, most probable r = a0 = 1 (atomic units)
383        let dr = 0.01;
384        let n_pts = 1000;
385        let mut max_prob = 0.0;
386        let mut max_r = 0.0;
387        for i in 1..n_pts {
388            let r = i as f64 * dr;
389            let p = radial_probability(1, 0, r);
390            if p > max_prob {
391                max_prob = p;
392                max_r = r;
393            }
394        }
395        assert!((max_r - 1.0).abs() < 0.1, "Most probable r = {}", max_r);
396    }
397
398    #[test]
399    fn test_renderer() {
400        let renderer = HydrogenRenderer::new(10, 5.0);
401        let grid = renderer.render(1, 0, 0, SlicePlane::XZ);
402        assert_eq!(grid.len(), 10);
403        assert_eq!(grid[0].len(), 10);
404    }
405
406    #[test]
407    fn test_render_orbital_slice() {
408        let data = render_orbital_slice(2, 1, 0, SlicePlane::XZ, 8, 5.0);
409        assert_eq!(data.len(), 64);
410    }
411}