Skip to main content

proof_engine/electromagnetic/
fdtd.rs

1//! Maxwell's equations FDTD (Finite-Difference Time-Domain) solver.
2//!
3//! Implements the Yee cell algorithm with leapfrog time-stepping for both
4//! 2D TM mode and full 3D electromagnetic field simulation.
5
6use glam::{Vec3, Vec4};
7
8// ── 3D FDTD Grid (Yee cell) ──────────────────────────────────────────────
9
10/// Full 3D FDTD grid using the Yee staggered-grid scheme.
11pub struct FdtdGrid {
12    pub ex: Vec<f32>,
13    pub ey: Vec<f32>,
14    pub ez: Vec<f32>,
15    pub hx: Vec<f32>,
16    pub hy: Vec<f32>,
17    pub hz: Vec<f32>,
18    pub nx: usize,
19    pub ny: usize,
20    pub nz: usize,
21    pub dx: f32,
22    pub dt: f32,
23}
24
25impl FdtdGrid {
26    pub fn new(nx: usize, ny: usize, nz: usize, dx: f32, dt: f32) -> Self {
27        let size = nx * ny * nz;
28        Self {
29            ex: vec![0.0; size],
30            ey: vec![0.0; size],
31            ez: vec![0.0; size],
32            hx: vec![0.0; size],
33            hy: vec![0.0; size],
34            hz: vec![0.0; size],
35            nx,
36            ny,
37            nz,
38            dx,
39            dt,
40        }
41    }
42
43    fn idx(&self, x: usize, y: usize, z: usize) -> usize {
44        x + y * self.nx + z * self.nx * self.ny
45    }
46
47    /// Advance one timestep using leapfrog: update H then E.
48    pub fn step(&mut self) {
49        let c = self.dt / self.dx;
50        // Update H fields (half step ahead)
51        for z in 0..self.nz - 1 {
52            for y in 0..self.ny - 1 {
53                for x in 0..self.nx - 1 {
54                    let i = self.idx(x, y, z);
55                    let ix = self.idx(x + 1, y, z);
56                    let iy = self.idx(x, y + 1, z);
57                    let iz = self.idx(x, y, z + 1);
58
59                    self.hx[i] -= c * ((self.ez[iy] - self.ez[i]) - (self.ey[iz] - self.ey[i]));
60                    self.hy[i] -= c * ((self.ex[iz] - self.ex[i]) - (self.ez[ix] - self.ez[i]));
61                    self.hz[i] -= c * ((self.ey[ix] - self.ey[i]) - (self.ex[iy] - self.ex[i]));
62                }
63            }
64        }
65        // Update E fields
66        for z in 1..self.nz {
67            for y in 1..self.ny {
68                for x in 1..self.nx {
69                    let i = self.idx(x, y, z);
70                    let ix = self.idx(x - 1, y, z);
71                    let iy = self.idx(x, y - 1, z);
72                    let iz = self.idx(x, y, z - 1);
73
74                    self.ex[i] += c * ((self.hz[i] - self.hz[iy]) - (self.hy[i] - self.hy[iz]));
75                    self.ey[i] += c * ((self.hx[i] - self.hx[iz]) - (self.hz[i] - self.hz[ix]));
76                    self.ez[i] += c * ((self.hy[i] - self.hy[ix]) - (self.hx[i] - self.hx[iy]));
77                }
78            }
79        }
80    }
81
82    /// Total electromagnetic energy in the grid.
83    pub fn field_energy(&self) -> f32 {
84        let mut energy = 0.0f32;
85        let vol = self.dx * self.dx * self.dx;
86        for i in 0..self.ex.len() {
87            let e2 = self.ex[i] * self.ex[i] + self.ey[i] * self.ey[i] + self.ez[i] * self.ez[i];
88            let h2 = self.hx[i] * self.hx[i] + self.hy[i] * self.hy[i] + self.hz[i] * self.hz[i];
89            // E energy: 0.5 * eps0 * E^2, H energy: 0.5 * mu0 * H^2
90            // Using normalized units (eps0=1, mu0=1)
91            energy += 0.5 * (e2 + h2) * vol;
92        }
93        energy
94    }
95
96    /// Courant number for 3D: dt * c / dx. Must be < 1/sqrt(3) for stability.
97    pub fn courant_number(&self) -> f32 {
98        // In normalized units c=1
99        self.dt / self.dx
100    }
101
102    /// Inject a soft source at grid point (x, y, z) into Ez.
103    pub fn add_source(&mut self, x: usize, y: usize, z: usize, value: f32) {
104        let i = self.idx(x, y, z);
105        if i < self.ez.len() {
106            self.ez[i] += value;
107        }
108    }
109}
110
111// ── 2D FDTD Grid (TM mode) ───────────────────────────────────────────────
112
113/// 2D FDTD grid for TM (Transverse Magnetic) mode: Ez, Hx, Hy.
114pub struct FdtdGrid2D {
115    pub ez: Vec<f32>,
116    pub hx: Vec<f32>,
117    pub hy: Vec<f32>,
118    pub nx: usize,
119    pub ny: usize,
120    pub dx: f32,
121    pub dt: f32,
122    pub permittivity: Vec<f32>,
123    pub permeability: Vec<f32>,
124    pub conductivity: Vec<f32>,
125    // PML coefficient arrays
126    pml_ez: Vec<f32>,
127    pml_hx: Vec<f32>,
128    pml_hy: Vec<f32>,
129    // Mur ABC: store previous boundary values
130    mur_prev_left: Vec<f32>,
131    mur_prev_right: Vec<f32>,
132    mur_prev_top: Vec<f32>,
133    mur_prev_bottom: Vec<f32>,
134}
135
136impl FdtdGrid2D {
137    pub fn new(nx: usize, ny: usize, dx: f32, dt: f32) -> Self {
138        let size = nx * ny;
139        Self {
140            ez: vec![0.0; size],
141            hx: vec![0.0; size],
142            hy: vec![0.0; size],
143            nx,
144            ny,
145            dx,
146            dt,
147            permittivity: vec![1.0; size],
148            permeability: vec![1.0; size],
149            conductivity: vec![0.0; size],
150            pml_ez: vec![0.0; size],
151            pml_hx: vec![0.0; size],
152            pml_hy: vec![0.0; size],
153            mur_prev_left: vec![0.0; ny],
154            mur_prev_right: vec![0.0; ny],
155            mur_prev_top: vec![0.0; nx],
156            mur_prev_bottom: vec![0.0; nx],
157        }
158    }
159
160    fn idx(&self, x: usize, y: usize) -> usize {
161        x + y * self.nx
162    }
163
164    /// Advance one timestep using the leapfrog scheme.
165    /// Updates H first (half-step), then E (full step).
166    pub fn step(&mut self) {
167        let dt = self.dt;
168        let dx = self.dx;
169
170        // Update Hx: dHx/dt = -(1/mu) * dEz/dy
171        for y in 0..self.ny - 1 {
172            for x in 0..self.nx {
173                let i = self.idx(x, y);
174                let iy = self.idx(x, y + 1);
175                let mu = self.permeability[i];
176                self.hx[i] -= (dt / (mu * dx)) * (self.ez[iy] - self.ez[i]);
177            }
178        }
179
180        // Update Hy: dHy/dt = (1/mu) * dEz/dx
181        for y in 0..self.ny {
182            for x in 0..self.nx - 1 {
183                let i = self.idx(x, y);
184                let ix = self.idx(x + 1, y);
185                let mu = self.permeability[i];
186                self.hy[i] += (dt / (mu * dx)) * (self.ez[ix] - self.ez[i]);
187            }
188        }
189
190        // Update Ez: dEz/dt = (1/eps) * (dHy/dx - dHx/dy) - (sigma/eps)*Ez
191        for y in 1..self.ny {
192            for x in 1..self.nx {
193                let i = self.idx(x, y);
194                let ix = self.idx(x - 1, y);
195                let iy = self.idx(x, y - 1);
196                let eps = self.permittivity[i];
197                let sigma = self.conductivity[i];
198
199                let curl_h = (self.hy[i] - self.hy[ix]) / dx - (self.hx[i] - self.hx[iy]) / dx;
200
201                // Lossy update equation
202                let ca = (1.0 - sigma * dt / (2.0 * eps)) / (1.0 + sigma * dt / (2.0 * eps));
203                let cb = (dt / eps) / (1.0 + sigma * dt / (2.0 * eps));
204                self.ez[i] = ca * self.ez[i] + cb * curl_h;
205            }
206        }
207    }
208
209    /// Inject a soft source at point (x, y) into Ez.
210    pub fn add_source(&mut self, x: usize, y: usize, value: f32) {
211        let i = self.idx(x, y);
212        if i < self.ez.len() {
213            self.ez[i] += value;
214        }
215    }
216
217    /// Total electromagnetic energy in the 2D grid.
218    pub fn field_energy(&self) -> f32 {
219        let mut energy = 0.0f32;
220        let area = self.dx * self.dx;
221        for i in 0..self.ez.len() {
222            let eps = self.permittivity[i];
223            let mu = self.permeability[i];
224            let e2 = self.ez[i] * self.ez[i];
225            let h2 = self.hx[i] * self.hx[i] + self.hy[i] * self.hy[i];
226            energy += 0.5 * (eps * e2 + mu * h2) * area;
227        }
228        energy
229    }
230
231    /// Courant number for 2D: dt * c / dx. Must be < 1/sqrt(2) for stability.
232    pub fn courant_number(&self) -> f32 {
233        // c = 1/sqrt(eps*mu), for free space eps=mu=1 so c=1
234        self.dt / self.dx
235    }
236
237    /// Apply Perfectly Matched Layer absorbing boundary conditions.
238    /// `layers` is the number of PML cells on each side.
239    pub fn apply_pml_boundary(&mut self, layers: usize) {
240        let nx = self.nx;
241        let ny = self.ny;
242        let sigma_max = 3.0; // Maximum conductivity at PML edge
243
244        for y in 0..ny {
245            for x in 0..nx {
246                let i = self.idx(x, y);
247
248                // Distance into PML region (0 = not in PML)
249                let dx_left = if x < layers { (layers - x) as f32 / layers as f32 } else { 0.0 };
250                let dx_right = if x >= nx - layers { (x - (nx - layers - 1)) as f32 / layers as f32 } else { 0.0 };
251                let dy_bottom = if y < layers { (layers - y) as f32 / layers as f32 } else { 0.0 };
252                let dy_top = if y >= ny - layers { (y - (ny - layers - 1)) as f32 / layers as f32 } else { 0.0 };
253
254                let sigma_x = sigma_max * (dx_left.max(dx_right)).powi(2);
255                let sigma_y = sigma_max * (dy_bottom.max(dy_top)).powi(2);
256                let sigma = sigma_x + sigma_y;
257
258                if sigma > 0.0 {
259                    // Exponential damping in PML region
260                    let decay = (-sigma * self.dt).exp();
261                    self.ez[i] *= decay;
262                    self.hx[i] *= decay;
263                    self.hy[i] *= decay;
264                }
265            }
266        }
267    }
268
269    /// Apply first-order Mur absorbing boundary condition.
270    pub fn apply_mur_abc(&mut self) {
271        let nx = self.nx;
272        let ny = self.ny;
273        // c_dt_dx = (c*dt - dx) / (c*dt + dx), with c=1 in normalized units
274        let c = 1.0_f32; // speed of light in normalized units
275        let coeff = (c * self.dt - self.dx) / (c * self.dt + self.dx);
276
277        // Left boundary (x=0)
278        for y in 0..ny {
279            let i0 = self.idx(0, y);
280            let i1 = self.idx(1, y);
281            let new_val = self.mur_prev_left[y] + coeff * (self.ez[i1] - self.ez[i0]);
282            self.mur_prev_left[y] = self.ez[i1];
283            self.ez[i0] = new_val;
284        }
285
286        // Right boundary (x=nx-1)
287        for y in 0..ny {
288            let i0 = self.idx(nx - 1, y);
289            let i1 = self.idx(nx - 2, y);
290            let new_val = self.mur_prev_right[y] + coeff * (self.ez[i1] - self.ez[i0]);
291            self.mur_prev_right[y] = self.ez[i1];
292            self.ez[i0] = new_val;
293        }
294
295        // Bottom boundary (y=0)
296        for x in 0..nx {
297            let i0 = self.idx(x, 0);
298            let i1 = self.idx(x, 1);
299            let new_val = self.mur_prev_bottom[x] + coeff * (self.ez[i1] - self.ez[i0]);
300            self.mur_prev_bottom[x] = self.ez[i1];
301            self.ez[i0] = new_val;
302        }
303
304        // Top boundary (y=ny-1)
305        for x in 0..nx {
306            let i0 = self.idx(x, ny - 1);
307            let i1 = self.idx(x, ny - 2);
308            let new_val = self.mur_prev_top[x] + coeff * (self.ez[i1] - self.ez[i0]);
309            self.mur_prev_top[x] = self.ez[i1];
310            self.ez[i0] = new_val;
311        }
312    }
313
314    /// Set material properties for a rectangular region.
315    pub fn set_material_region(
316        &mut self,
317        x0: usize, y0: usize,
318        x1: usize, y1: usize,
319        eps: f32, mu: f32, sigma: f32,
320    ) {
321        for y in y0..y1.min(self.ny) {
322            for x in x0..x1.min(self.nx) {
323                let i = self.idx(x, y);
324                self.permittivity[i] = eps;
325                self.permeability[i] = mu;
326                self.conductivity[i] = sigma;
327            }
328        }
329    }
330}
331
332// ── Source waveforms ──────────────────────────────────────────────────────
333
334/// Gaussian pulse source waveform.
335pub fn gaussian_pulse(t: f32, t0: f32, spread: f32) -> f32 {
336    let arg = (t - t0) / spread;
337    (-0.5 * arg * arg).exp()
338}
339
340/// Sinusoidal source waveform.
341pub fn sinusoidal_source(t: f32, freq: f32) -> f32 {
342    (2.0 * std::f32::consts::PI * freq * t).sin()
343}
344
345// ── Material Grid ─────────────────────────────────────────────────────────
346
347/// Heterogeneous material specification for FDTD grids.
348pub struct MaterialGrid {
349    pub permittivity: Vec<f32>,
350    pub permeability: Vec<f32>,
351    pub conductivity: Vec<f32>,
352    pub nx: usize,
353    pub ny: usize,
354}
355
356impl MaterialGrid {
357    pub fn new(nx: usize, ny: usize) -> Self {
358        let size = nx * ny;
359        Self {
360            permittivity: vec![1.0; size],
361            permeability: vec![1.0; size],
362            conductivity: vec![0.0; size],
363            nx,
364            ny,
365        }
366    }
367
368    pub fn set(&mut self, x: usize, y: usize, eps: f32, mu: f32, sigma: f32) {
369        let i = x + y * self.nx;
370        if i < self.permittivity.len() {
371            self.permittivity[i] = eps;
372            self.permeability[i] = mu;
373            self.conductivity[i] = sigma;
374        }
375    }
376
377    pub fn get(&self, x: usize, y: usize) -> (f32, f32, f32) {
378        let i = x + y * self.nx;
379        (self.permittivity[i], self.permeability[i], self.conductivity[i])
380    }
381
382    /// Apply this material grid to a FdtdGrid2D.
383    pub fn apply_to(&self, grid: &mut FdtdGrid2D) {
384        assert_eq!(self.nx, grid.nx);
385        assert_eq!(self.ny, grid.ny);
386        grid.permittivity = self.permittivity.clone();
387        grid.permeability = self.permeability.clone();
388        grid.conductivity = self.conductivity.clone();
389    }
390
391    /// Create a dielectric slab from x0 to x1 across the full height.
392    pub fn add_dielectric_slab(&mut self, x0: usize, x1: usize, eps: f32) {
393        for y in 0..self.ny {
394            for x in x0..x1.min(self.nx) {
395                let i = x + y * self.nx;
396                self.permittivity[i] = eps;
397            }
398        }
399    }
400
401    /// Create a conducting region.
402    pub fn add_conductor(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, sigma: f32) {
403        for y in y0..y1.min(self.ny) {
404            for x in x0..x1.min(self.nx) {
405                let i = x + y * self.nx;
406                self.conductivity[i] = sigma;
407            }
408        }
409    }
410}
411
412// ── FDTD Renderer ─────────────────────────────────────────────────────────
413
414/// Renders FDTD field data as colored glyphs.
415/// Blue = negative, Red = positive, brightness = magnitude.
416pub struct FdtdRenderer {
417    pub scale: f32,
418    pub field_component: FieldComponent,
419}
420
421#[derive(Clone, Copy, Debug)]
422pub enum FieldComponent {
423    Ez,
424    Hx,
425    Hy,
426    Energy,
427}
428
429impl FdtdRenderer {
430    pub fn new(scale: f32, component: FieldComponent) -> Self {
431        Self {
432            scale,
433            field_component: component,
434        }
435    }
436
437    /// Sample a 2D FDTD grid and return (x, y, color) triples for rendering.
438    pub fn render_grid(&self, grid: &FdtdGrid2D) -> Vec<(usize, usize, Vec4)> {
439        let mut result = Vec::with_capacity(grid.nx * grid.ny);
440        for y in 0..grid.ny {
441            for x in 0..grid.nx {
442                let i = x + y * grid.nx;
443                let val = match self.field_component {
444                    FieldComponent::Ez => grid.ez[i],
445                    FieldComponent::Hx => grid.hx[i],
446                    FieldComponent::Hy => grid.hy[i],
447                    FieldComponent::Energy => {
448                        let e2 = grid.ez[i] * grid.ez[i];
449                        let h2 = grid.hx[i] * grid.hx[i] + grid.hy[i] * grid.hy[i];
450                        (e2 + h2).sqrt()
451                    }
452                };
453
454                let normalized = (val * self.scale).clamp(-1.0, 1.0);
455                let brightness = normalized.abs();
456                let color = if normalized > 0.0 {
457                    Vec4::new(brightness, 0.1 * brightness, 0.05 * brightness, brightness.max(0.01))
458                } else {
459                    Vec4::new(0.05 * brightness, 0.1 * brightness, brightness, brightness.max(0.01))
460                };
461                result.push((x, y, color));
462            }
463        }
464        result
465    }
466
467    /// Return the glyph character based on field magnitude.
468    pub fn glyph_for_magnitude(magnitude: f32) -> char {
469        if magnitude > 0.8 {
470            '█'
471        } else if magnitude > 0.6 {
472            '▓'
473        } else if magnitude > 0.4 {
474            '▒'
475        } else if magnitude > 0.2 {
476            '░'
477        } else if magnitude > 0.05 {
478            '·'
479        } else {
480            ' '
481        }
482    }
483}
484
485// ── Tests ─────────────────────────────────────────────────────────────────
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn test_gaussian_pulse() {
493        let peak = gaussian_pulse(5.0, 5.0, 1.0);
494        assert!((peak - 1.0).abs() < 1e-6);
495        let off = gaussian_pulse(0.0, 5.0, 1.0);
496        assert!(off < 0.01);
497    }
498
499    #[test]
500    fn test_sinusoidal_source() {
501        let val = sinusoidal_source(0.0, 1.0);
502        assert!(val.abs() < 1e-6);
503        let quarter = sinusoidal_source(0.25, 1.0);
504        assert!((quarter - 1.0).abs() < 1e-5);
505    }
506
507    #[test]
508    fn test_courant_number_2d() {
509        let grid = FdtdGrid2D::new(50, 50, 1.0, 0.5);
510        let cn = grid.courant_number();
511        assert!((cn - 0.5).abs() < 1e-6);
512        // Must be < 1/sqrt(2) ≈ 0.707 for 2D stability
513        assert!(cn < 1.0 / 2.0_f32.sqrt());
514    }
515
516    #[test]
517    fn test_courant_number_3d() {
518        let grid = FdtdGrid::new(10, 10, 10, 1.0, 0.3);
519        let cn = grid.courant_number();
520        assert!((cn - 0.3).abs() < 1e-6);
521        // Must be < 1/sqrt(3) ≈ 0.577 for 3D stability
522        assert!(cn < 1.0 / 3.0_f32.sqrt());
523    }
524
525    #[test]
526    fn test_energy_conservation_no_source() {
527        // A closed system with no boundaries should conserve energy approximately
528        let mut grid = FdtdGrid2D::new(30, 30, 1.0, 0.4);
529        // Inject a pulse in center
530        grid.add_source(15, 15, 1.0);
531        let e0 = grid.field_energy();
532        assert!(e0 > 0.0);
533
534        // Step a few times (interior only, no ABC)
535        for _ in 0..5 {
536            grid.step();
537        }
538        let e1 = grid.field_energy();
539        // Energy should be roughly conserved (won't be exact due to boundaries)
540        // Just verify it's positive and not wildly divergent
541        assert!(e1 > 0.0);
542        assert!(e1 < e0 * 10.0); // Not blowing up
543    }
544
545    #[test]
546    fn test_wave_speed() {
547        // In normalized units (eps=1, mu=1), wave speed = 1/sqrt(eps*mu) = 1.
548        // After N steps, a pulse should travel approximately N*dt/dx cells.
549        let nx = 100;
550        let ny = 5;
551        let dx = 1.0;
552        let dt = 0.5; // courant = 0.5
553        let mut grid = FdtdGrid2D::new(nx, ny, dx, dt);
554        let src_x = 10;
555        let src_y = 2;
556
557        // Run with source for a few steps
558        for t in 0..40 {
559            let pulse = gaussian_pulse(t as f32 * dt, 5.0, 2.0);
560            grid.add_source(src_x, src_y, pulse);
561            grid.step();
562        }
563
564        // Find peak position of Ez along y=2
565        let mut max_val = 0.0f32;
566        let mut max_x = 0usize;
567        for x in src_x + 1..nx {
568            let i = x + src_y * nx;
569            if grid.ez[i].abs() > max_val {
570                max_val = grid.ez[i].abs();
571                max_x = x;
572            }
573        }
574
575        // The wave should have propagated rightward from src_x
576        // Expected distance: ~40 steps * dt * c / dx = 40*0.5*1/1 = 20 cells
577        // Allow generous tolerance due to dispersion
578        assert!(max_x > src_x, "Wave should propagate away from source");
579        assert!(max_x < src_x + 30, "Wave should not exceed speed of light");
580    }
581
582    #[test]
583    fn test_cfl_stability() {
584        // Verify that a simulation with courant < 1/sqrt(2) remains bounded
585        let mut grid = FdtdGrid2D::new(40, 40, 1.0, 0.45);
586        assert!(grid.courant_number() < 1.0 / 2.0_f32.sqrt());
587        grid.add_source(20, 20, 10.0);
588        for _ in 0..100 {
589            grid.step();
590        }
591        // Check no NaN or Inf
592        for val in &grid.ez {
593            assert!(val.is_finite(), "Field should remain finite with stable CFL");
594        }
595    }
596
597    #[test]
598    fn test_pml_absorbs_energy() {
599        let mut grid = FdtdGrid2D::new(60, 60, 1.0, 0.4);
600        grid.add_source(30, 30, 5.0);
601        // Run without PML first
602        for _ in 0..20 {
603            grid.step();
604        }
605        let e_no_pml = grid.field_energy();
606
607        // Reset and run with PML
608        let mut grid2 = FdtdGrid2D::new(60, 60, 1.0, 0.4);
609        grid2.add_source(30, 30, 5.0);
610        for _ in 0..20 {
611            grid2.step();
612            grid2.apply_pml_boundary(8);
613        }
614        let e_pml = grid2.field_energy();
615
616        // PML should reduce energy at boundaries
617        assert!(e_pml < e_no_pml, "PML should absorb outgoing waves");
618    }
619
620    #[test]
621    fn test_material_grid() {
622        let mut mat = MaterialGrid::new(10, 10);
623        mat.add_dielectric_slab(3, 7, 4.0);
624        assert!((mat.permittivity[5 + 5 * 10] - 4.0).abs() < 1e-6);
625        assert!((mat.permittivity[1 + 5 * 10] - 1.0).abs() < 1e-6);
626    }
627
628    #[test]
629    fn test_3d_grid_step() {
630        let mut grid = FdtdGrid::new(10, 10, 10, 1.0, 0.3);
631        grid.add_source(5, 5, 5, 1.0);
632        let e0 = grid.field_energy();
633        assert!(e0 > 0.0);
634        grid.step();
635        let e1 = grid.field_energy();
636        assert!(e1 > 0.0);
637        for val in &grid.ez {
638            assert!(val.is_finite());
639        }
640    }
641
642    #[test]
643    fn test_mur_abc() {
644        let mut grid = FdtdGrid2D::new(50, 50, 1.0, 0.4);
645        grid.add_source(25, 25, 5.0);
646        for _ in 0..30 {
647            grid.step();
648            grid.apply_mur_abc();
649        }
650        // Boundaries should be small after Mur ABC
651        for y in 0..grid.ny {
652            let left = grid.ez[grid.idx(0, y)];
653            let right = grid.ez[grid.idx(grid.nx - 1, y)];
654            assert!(left.abs() < 5.0, "Mur ABC should limit boundary reflections");
655            assert!(right.abs() < 5.0);
656        }
657    }
658
659    #[test]
660    fn test_renderer() {
661        let mut grid = FdtdGrid2D::new(10, 10, 1.0, 0.5);
662        grid.ez[55] = 0.5;
663        grid.ez[44] = -0.3;
664        let renderer = FdtdRenderer::new(2.0, FieldComponent::Ez);
665        let pixels = renderer.render_grid(&grid);
666        assert_eq!(pixels.len(), 100);
667        // Positive value -> red channel dominant
668        let (_, _, c) = pixels[55];
669        assert!(c.x > c.z);
670        // Negative value -> blue channel dominant
671        let (_, _, c) = pixels[44];
672        assert!(c.z > c.x);
673    }
674
675    #[test]
676    fn test_glyph_for_magnitude() {
677        assert_eq!(FdtdRenderer::glyph_for_magnitude(0.9), '█');
678        assert_eq!(FdtdRenderer::glyph_for_magnitude(0.5), '▒');
679        assert_eq!(FdtdRenderer::glyph_for_magnitude(0.01), ' ');
680    }
681}