Skip to main content

proof_engine/electromagnetic/
plasma.rs

1//! Plasma simulation using the Particle-In-Cell (PIC) method.
2//! Supports electrostatic PIC with CIC deposition, Poisson solver,
3//! and Boris particle pushing.
4
5use glam::{Vec2, Vec4};
6use std::f32::consts::PI;
7
8// ── PIC Particle ──────────────────────────────────────────────────────────
9
10#[derive(Clone, Debug)]
11pub struct PicParticle {
12    pub x: Vec2,
13    pub v: Vec2,
14    pub charge: f32,
15    pub mass: f32,
16    pub species: u8, // 0 = electron, 1 = ion
17}
18
19impl PicParticle {
20    pub fn new(x: Vec2, v: Vec2, charge: f32, mass: f32, species: u8) -> Self {
21        Self { x, v, charge, mass, species }
22    }
23
24    pub fn electron(x: Vec2, v: Vec2) -> Self {
25        Self { x, v, charge: -1.0, mass: 1.0, species: 0 }
26    }
27
28    pub fn ion(x: Vec2, v: Vec2) -> Self {
29        Self { x, v, charge: 1.0, mass: 1836.0, species: 1 }
30    }
31}
32
33// ── PIC Grid ──────────────────────────────────────────────────────────────
34
35#[derive(Clone, Debug)]
36pub struct PicGrid {
37    pub rho: Vec<f32>,  // charge density
38    pub phi: Vec<f32>,  // electrostatic potential
39    pub ex: Vec<f32>,   // electric field x
40    pub ey: Vec<f32>,   // electric field y
41    pub nx: usize,
42    pub ny: usize,
43    pub dx: f32,
44}
45
46impl PicGrid {
47    pub fn new(nx: usize, ny: usize, dx: f32) -> Self {
48        let size = nx * ny;
49        Self {
50            rho: vec![0.0; size],
51            phi: vec![0.0; size],
52            ex: vec![0.0; size],
53            ey: vec![0.0; size],
54            nx,
55            ny,
56            dx,
57        }
58    }
59
60    fn idx(&self, x: usize, y: usize) -> usize {
61        x.min(self.nx - 1) + y.min(self.ny - 1) * self.nx
62    }
63
64    /// Clear charge density for re-deposition.
65    pub fn clear_rho(&mut self) {
66        for v in &mut self.rho {
67            *v = 0.0;
68        }
69    }
70
71    /// Total field energy: 0.5 * sum(eps0 * E^2) * dx^2
72    pub fn field_energy(&self) -> f32 {
73        let mut energy = 0.0f32;
74        let area = self.dx * self.dx;
75        for i in 0..self.ex.len() {
76            energy += 0.5 * (self.ex[i] * self.ex[i] + self.ey[i] * self.ey[i]) * area;
77        }
78        energy
79    }
80}
81
82// ── PIC Simulation ────────────────────────────────────────────────────────
83
84pub struct PicSimulation {
85    pub particles: Vec<PicParticle>,
86    pub grid: PicGrid,
87    pub dt: f32,
88    pub time: f32,
89}
90
91impl PicSimulation {
92    pub fn new(nx: usize, ny: usize, dx: f32, dt: f32) -> Self {
93        Self {
94            particles: Vec::new(),
95            grid: PicGrid::new(nx, ny, dx),
96            dt,
97            time: 0.0,
98        }
99    }
100
101    /// CIC (Cloud-In-Cell) charge deposition: distribute particle charge to grid.
102    pub fn deposit_charge(&mut self) {
103        self.grid.clear_rho();
104        let nx = self.grid.nx;
105        let ny = self.grid.ny;
106        let dx = self.grid.dx;
107        let inv_area = 1.0 / (dx * dx);
108
109        for p in &self.particles {
110            // Grid coordinates
111            let gx = p.x.x / dx;
112            let gy = p.x.y / dx;
113            let ix = gx.floor() as i32;
114            let iy = gy.floor() as i32;
115            let fx = gx - ix as f32;
116            let fy = gy - iy as f32;
117
118            // CIC weights to 4 surrounding cells
119            let weights = [
120                ((1.0 - fx) * (1.0 - fy), ix, iy),
121                (fx * (1.0 - fy), ix + 1, iy),
122                ((1.0 - fx) * fy, ix, iy + 1),
123                (fx * fy, ix + 1, iy + 1),
124            ];
125
126            for &(w, cx, cy) in &weights {
127                // Periodic boundary conditions
128                let cx = ((cx % nx as i32) + nx as i32) as usize % nx;
129                let cy = ((cy % ny as i32) + ny as i32) as usize % ny;
130                let idx = self.grid.idx(cx, cy);
131                self.grid.rho[idx] += p.charge * w * inv_area;
132            }
133        }
134    }
135
136    /// Solve Poisson equation: ∇²φ = -ρ/ε₀ using SOR (Successive Over-Relaxation).
137    pub fn solve_poisson(&mut self) {
138        let nx = self.grid.nx;
139        let ny = self.grid.ny;
140        let dx2 = self.grid.dx * self.grid.dx;
141        let omega = 1.8; // SOR relaxation parameter
142        let max_iter = 200;
143        let tolerance = 1e-5;
144
145        for _iter in 0..max_iter {
146            let mut max_diff = 0.0f32;
147            for y in 0..ny {
148                for x in 0..nx {
149                    let i = self.grid.idx(x, y);
150                    // Periodic neighbors
151                    let xm = if x == 0 { nx - 1 } else { x - 1 };
152                    let xp = if x == nx - 1 { 0 } else { x + 1 };
153                    let ym = if y == 0 { ny - 1 } else { y - 1 };
154                    let yp = if y == ny - 1 { 0 } else { y + 1 };
155
156                    let phi_neighbors = self.grid.phi[self.grid.idx(xm, y)]
157                        + self.grid.phi[self.grid.idx(xp, y)]
158                        + self.grid.phi[self.grid.idx(x, ym)]
159                        + self.grid.phi[self.grid.idx(x, yp)];
160
161                    let new_phi = 0.25 * (phi_neighbors + dx2 * self.grid.rho[i]);
162                    let diff = (new_phi - self.grid.phi[i]).abs();
163                    max_diff = max_diff.max(diff);
164                    self.grid.phi[i] = (1.0 - omega) * self.grid.phi[i] + omega * new_phi;
165                }
166            }
167            if max_diff < tolerance {
168                break;
169            }
170        }
171    }
172
173    /// Compute electric field from potential: E = -∇φ (central differences).
174    pub fn compute_field(&mut self) {
175        let nx = self.grid.nx;
176        let ny = self.grid.ny;
177        let inv_2dx = 1.0 / (2.0 * self.grid.dx);
178
179        for y in 0..ny {
180            for x in 0..nx {
181                let i = self.grid.idx(x, y);
182                let xm = if x == 0 { nx - 1 } else { x - 1 };
183                let xp = if x == nx - 1 { 0 } else { x + 1 };
184                let ym = if y == 0 { ny - 1 } else { y - 1 };
185                let yp = if y == ny - 1 { 0 } else { y + 1 };
186
187                self.grid.ex[i] = -(self.grid.phi[self.grid.idx(xp, y)] - self.grid.phi[self.grid.idx(xm, y)]) * inv_2dx;
188                self.grid.ey[i] = -(self.grid.phi[self.grid.idx(x, yp)] - self.grid.phi[self.grid.idx(x, ym)]) * inv_2dx;
189            }
190        }
191    }
192
193    /// Push particles using Boris algorithm with interpolated fields.
194    pub fn push_particles(&mut self) {
195        let nx = self.grid.nx;
196        let ny = self.grid.ny;
197        let dx = self.grid.dx;
198        let dt = self.dt;
199        let lx = nx as f32 * dx;
200        let ly = ny as f32 * dx;
201
202        for p in &mut self.particles {
203            // Interpolate E field at particle position (CIC)
204            let gx = p.x.x / dx;
205            let gy = p.x.y / dx;
206            let ix = gx.floor() as i32;
207            let iy = gy.floor() as i32;
208            let fx = gx - ix as f32;
209            let fy = gy - iy as f32;
210
211            let mut ex_p = 0.0f32;
212            let mut ey_p = 0.0f32;
213
214            let weights = [
215                ((1.0 - fx) * (1.0 - fy), ix, iy),
216                (fx * (1.0 - fy), ix + 1, iy),
217                ((1.0 - fx) * fy, ix, iy + 1),
218                (fx * fy, ix + 1, iy + 1),
219            ];
220
221            for &(w, cx, cy) in &weights {
222                let cx = ((cx % nx as i32) + nx as i32) as usize % nx;
223                let cy = ((cy % ny as i32) + ny as i32) as usize % ny;
224                let idx = cx + cy * nx;
225                ex_p += w * self.grid.ex[idx];
226                ey_p += w * self.grid.ey[idx];
227            }
228
229            // Leapfrog velocity update: v(t+dt/2) = v(t-dt/2) + q/m * E * dt
230            let qm = p.charge / p.mass;
231            p.v.x += qm * ex_p * dt;
232            p.v.y += qm * ey_p * dt;
233
234            // Position update: x(t+dt) = x(t) + v(t+dt/2) * dt
235            p.x.x += p.v.x * dt;
236            p.x.y += p.v.y * dt;
237
238            // Periodic boundary conditions
239            p.x.x = ((p.x.x % lx) + lx) % lx;
240            p.x.y = ((p.x.y % ly) + ly) % ly;
241        }
242    }
243
244    /// Full PIC cycle: deposit → solve → field → push.
245    pub fn step(&mut self) {
246        self.deposit_charge();
247        self.solve_poisson();
248        self.compute_field();
249        self.push_particles();
250        self.time += self.dt;
251    }
252
253    /// Initialize a two-stream instability: two counter-propagating beams.
254    pub fn initialize_two_stream(&mut self, n_per_species: usize, v_beam: f32) {
255        let nx = self.grid.nx;
256        let ny = self.grid.ny;
257        let dx = self.grid.dx;
258        let lx = nx as f32 * dx;
259        let ly = ny as f32 * dx;
260
261        self.particles.clear();
262
263        // Simple pseudo-random placement using a linear congruential pattern
264        let mut seed = 12345u64;
265        let next_rand = |s: &mut u64| -> f32 {
266            *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
267            ((*s >> 33) as f32) / (u32::MAX as f32 / 2.0)
268        };
269
270        for _ in 0..n_per_species {
271            let x1 = Vec2::new(next_rand(&mut seed) * lx, next_rand(&mut seed) * ly);
272            let x2 = Vec2::new(next_rand(&mut seed) * lx, next_rand(&mut seed) * ly);
273
274            // Stream 1: moving right
275            self.particles.push(PicParticle::new(
276                x1,
277                Vec2::new(v_beam, 0.0),
278                -1.0, 1.0, 0,
279            ));
280            // Stream 2: moving left
281            self.particles.push(PicParticle::new(
282                x2,
283                Vec2::new(-v_beam, 0.0),
284                -1.0, 1.0, 0,
285            ));
286        }
287
288        // Add stationary ion background (optional, just use neutralizing density)
289        // For simplicity, we skip explicit ions and assume a uniform neutralizing background
290    }
291
292    /// Initialize a Landau damping test: uniform plasma with a small density perturbation.
293    pub fn initialize_landau_damping(&mut self, n: usize, k: f32, amplitude: f32) {
294        let nx = self.grid.nx;
295        let ny = self.grid.ny;
296        let dx = self.grid.dx;
297        let lx = nx as f32 * dx;
298        let ly = ny as f32 * dx;
299
300        self.particles.clear();
301
302        let mut seed = 54321u64;
303        let next_rand = |s: &mut u64| -> f32 {
304            *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
305            ((*s >> 33) as f32) / (u32::MAX as f32 / 2.0)
306        };
307
308        for _ in 0..n {
309            // Uniform distribution with sinusoidal density perturbation
310            let mut x = next_rand(&mut seed) * lx;
311            let y = next_rand(&mut seed) * ly;
312
313            // Perturb position to create density modulation: x + amplitude/k * sin(k*x)
314            x += amplitude / k * (k * x).sin();
315            x = ((x % lx) + lx) % lx;
316
317            // Thermal velocity (Maxwellian-like using Box-Muller approximation)
318            let u1 = next_rand(&mut seed).abs().max(1e-10);
319            let u2 = next_rand(&mut seed);
320            let vth = 1.0;
321            let vx = vth * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos();
322            let vy = vth * (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).sin();
323
324            self.particles.push(PicParticle::new(
325                Vec2::new(x, y),
326                Vec2::new(vx, vy),
327                -1.0, 1.0, 0,
328            ));
329        }
330    }
331
332    /// Total kinetic energy of all particles.
333    pub fn kinetic_energy(&self) -> f32 {
334        self.particles.iter().map(|p| {
335            0.5 * p.mass * p.v.length_squared()
336        }).sum()
337    }
338
339    /// Total field energy.
340    pub fn field_energy(&self) -> f32 {
341        self.grid.field_energy()
342    }
343
344    /// Total energy (kinetic + field).
345    pub fn total_energy(&self) -> f32 {
346        self.kinetic_energy() + self.field_energy()
347    }
348
349    /// Total charge on the grid (should be conserved).
350    pub fn total_charge(&self) -> f32 {
351        self.particles.iter().map(|p| p.charge).sum()
352    }
353}
354
355// ── Plasma Parameters ─────────────────────────────────────────────────────
356
357/// Debye length: lambda_D = sqrt(eps0 * kT / (n * q^2))
358/// In normalized units with eps0=1, kT in eV, n = number density.
359pub fn debye_length(temp_ev: f32, density: f32) -> f32 {
360    if density < 1e-10 {
361        return f32::INFINITY;
362    }
363    // lambda_D = sqrt(T / n) in normalized units (eps0=1, q=1)
364    (temp_ev / density).sqrt()
365}
366
367/// Plasma frequency: omega_p = sqrt(n * q^2 / (eps0 * m))
368pub fn plasma_frequency(density: f32, mass: f32) -> f32 {
369    if mass < 1e-10 {
370        return 0.0;
371    }
372    // omega_p = sqrt(n / m) in normalized units
373    (density / mass).sqrt()
374}
375
376// ── Plasma Renderer ───────────────────────────────────────────────────────
377
378/// Renderer for PIC plasma visualization.
379pub struct PlasmaRenderer {
380    pub electron_color: Vec4,
381    pub ion_color: Vec4,
382    pub field_scale: f32,
383}
384
385impl PlasmaRenderer {
386    pub fn new() -> Self {
387        Self {
388            electron_color: Vec4::new(0.3, 0.5, 1.0, 0.8),
389            ion_color: Vec4::new(1.0, 0.3, 0.2, 0.8),
390            field_scale: 1.0,
391        }
392    }
393
394    /// Render particles as colored dots.
395    pub fn render_particles(&self, sim: &PicSimulation) -> Vec<(Vec2, Vec4)> {
396        sim.particles.iter().map(|p| {
397            let color = if p.species == 0 {
398                self.electron_color
399            } else {
400                self.ion_color
401            };
402            (p.x, color)
403        }).collect()
404    }
405
406    /// Render field as a background color grid.
407    pub fn render_field(&self, grid: &PicGrid) -> Vec<(usize, usize, Vec4)> {
408        let mut result = Vec::with_capacity(grid.nx * grid.ny);
409        for y in 0..grid.ny {
410            for x in 0..grid.nx {
411                let i = x + y * grid.nx;
412                let e_mag = (grid.ex[i] * grid.ex[i] + grid.ey[i] * grid.ey[i]).sqrt();
413                let brightness = (e_mag * self.field_scale).min(1.0);
414                let color = Vec4::new(brightness, brightness * 0.5, 0.0, brightness * 0.3);
415                result.push((x, y, color));
416            }
417        }
418        result
419    }
420
421    pub fn particle_glyph(species: u8) -> char {
422        match species {
423            0 => '·', // electron
424            1 => '●', // ion
425            _ => '○',
426        }
427    }
428}
429
430impl Default for PlasmaRenderer {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436// ── Tests ─────────────────────────────────────────────────────────────────
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_charge_conservation() {
444        let mut sim = PicSimulation::new(32, 32, 1.0, 0.1);
445        sim.initialize_two_stream(100, 1.0);
446        let q0 = sim.total_charge();
447        for _ in 0..10 {
448            sim.step();
449        }
450        let q1 = sim.total_charge();
451        assert!((q0 - q1).abs() < 1e-6, "Charge should be conserved: q0={}, q1={}", q0, q1);
452    }
453
454    #[test]
455    fn test_charge_deposition() {
456        let mut sim = PicSimulation::new(8, 8, 1.0, 0.1);
457        // Place a single particle at center of a cell
458        sim.particles.push(PicParticle::new(
459            Vec2::new(4.5, 4.5), Vec2::ZERO, 1.0, 1.0, 0,
460        ));
461        sim.deposit_charge();
462        // Total charge on grid should equal particle charge
463        let total: f32 = sim.grid.rho.iter().sum::<f32>() * sim.grid.dx * sim.grid.dx;
464        assert!((total - 1.0).abs() < 0.1, "Total deposited charge: {}", total);
465    }
466
467    #[test]
468    fn test_energy_conservation() {
469        let mut sim = PicSimulation::new(32, 32, 1.0, 0.05);
470        sim.initialize_two_stream(50, 0.5);
471        let e0 = sim.total_energy();
472        for _ in 0..5 {
473            sim.step();
474        }
475        let e1 = sim.total_energy();
476        // Energy should be approximately conserved (not exact due to numerical errors)
477        // Allow generous tolerance for PIC
478        let relative = if e0.abs() > 1e-10 { (e1 - e0).abs() / e0.abs() } else { (e1 - e0).abs() };
479        assert!(relative < 5.0, "Energy changed too much: e0={}, e1={}, rel={}", e0, e1, relative);
480    }
481
482    #[test]
483    fn test_debye_length() {
484        let ld = debye_length(1.0, 1.0);
485        assert!((ld - 1.0).abs() < 1e-6);
486        let ld2 = debye_length(4.0, 1.0);
487        assert!((ld2 - 2.0).abs() < 1e-6);
488    }
489
490    #[test]
491    fn test_plasma_frequency() {
492        let wp = plasma_frequency(4.0, 1.0);
493        assert!((wp - 2.0).abs() < 1e-6);
494    }
495
496    #[test]
497    fn test_poisson_solver() {
498        // A uniform charge density should give a quadratic potential
499        let mut sim = PicSimulation::new(16, 16, 1.0, 0.1);
500        for v in &mut sim.grid.rho {
501            *v = 1.0;
502        }
503        sim.solve_poisson();
504        // Potential should not be all zeros
505        let max_phi = sim.grid.phi.iter().cloned().fold(0.0f32, f32::max);
506        assert!(max_phi > 0.0, "Poisson solver should produce non-zero potential");
507    }
508
509    #[test]
510    fn test_field_from_potential() {
511        let mut sim = PicSimulation::new(16, 16, 1.0, 0.1);
512        // Linear potential in x: phi = x
513        for y in 0..16 {
514            for x in 0..16 {
515                sim.grid.phi[x + y * 16] = x as f32;
516            }
517        }
518        sim.compute_field();
519        // E_x = -dphi/dx = -1 everywhere (except boundaries)
520        let mid = sim.grid.ex[8 + 8 * 16];
521        assert!((mid - (-1.0)).abs() < 0.1, "Ex should be -1: {}", mid);
522    }
523
524    #[test]
525    fn test_two_stream_initialization() {
526        let mut sim = PicSimulation::new(32, 32, 1.0, 0.1);
527        sim.initialize_two_stream(100, 2.0);
528        assert_eq!(sim.particles.len(), 200);
529        // Should have equal numbers moving left and right
530        let right: usize = sim.particles.iter().filter(|p| p.v.x > 0.0).count();
531        let left: usize = sim.particles.iter().filter(|p| p.v.x < 0.0).count();
532        assert_eq!(right, 100);
533        assert_eq!(left, 100);
534    }
535
536    #[test]
537    fn test_debye_screening() {
538        // A point charge in a plasma should be screened over the Debye length
539        // This is more of a qualitative test
540        let ld = debye_length(1.0, 1.0);
541        assert!(ld > 0.0);
542        assert!(ld.is_finite());
543    }
544
545    #[test]
546    fn test_renderer() {
547        let sim = PicSimulation::new(8, 8, 1.0, 0.1);
548        let renderer = PlasmaRenderer::new();
549        let field = renderer.render_field(&sim.grid);
550        assert_eq!(field.len(), 64);
551    }
552}