Location: python/NOTEBOKS/FD-TD/ · Language: Python 3 + WGSL (WebGPU Shading Language) · Documented: 2026-07-11
This program solves the two-dimensional transverse-magnetic (TM$_z$) Maxwell equations with the Finite-Difference Time-Domain (FDTD) method of Yee [1], entirely on the GPU through the WebGPU compute API (wgpu-py). Absorbing boundaries are implemented with Bérenger's split-field Perfectly Matched Layer (PML) [3]. The user selects one of several pre-built scenes (lenses, optical fibers, refraction interfaces, a Shack–Hartmann-style wavefront sensor, etc.) defined in config.py; the simulation runs in a Jupyter notebook (main_gpu.ipynb) and displays a live false-color animation of the electric field
The program is a visual, interactive 2D electromagnetic wave simulator. It is intended for demonstrating and exploring wave-optics phenomena — refraction, focusing by lenses, guiding in optical fibers, anti-reflection structures, atmospheric distortion and wavefront sensing — rather than for quantitative engineering analysis.
What it does:
- Integrates the 2D TM$_z$ Maxwell equations on a uniform grid (default
$800 \times 416$ cells) with heterogeneous relative permittivity$\varepsilon_r(x,y)$ , relative permeability$\mu_r(x,y)$ , and electric conductivity$\sigma(x,y)$ . - Runs the update kernels on the GPU (WGSL compute shaders), keeping all field state in GPU buffers; only
$E_z$ is read back each frame for display. - Absorbs outgoing waves with a split-field PML [3] (an "anechoic chamber").
- Injects point sources: continuous sine, Gaussian pulse, or Gaussian-modulated sine burst.
What it does NOT do:
- No 3D, no dispersive or nonlinear media, no far-field transforms, no physical-unit input/output (everything is normalized).
- No TE$_z$ polarization (only
$E_z$ ,$H_x$ ,$H_y$ are evolved). - No file output of field data; the only outputs are the on-screen animation and timing printouts (the
output/folder holds manually saved screen recordings).
In a 2D problem invariant along
where dy = 1/max(Nx,Ny), config.py:97). All symbols below are therefore dimensionless; where a "unit" is quoted it is the normalized one (lengths in units of the domain height, times in units of a light-crossing time of the domain height, frequencies in inverse crossing times,
To terminate the grid without reflections, the outermost cells use Bérenger's perfectly matched layer [3].
each damped by an independent, direction-selective conductivity
A wave impinging on a vertical boundary varies along Ezix/Eziy (see the comment at wgpu_helper.py:99-101). The magnetic updates carry no magnetic loss in this implementation (c3 = c4 = dt/ur, util.py:163-164), so the PML is not perfectly matched in the discrete sense — it works because the conductivity profile is graded smoothly (Section 3.3) and the incident energy is absorbed over many cells.
The conductivity profile inside a layer of
with config.py:115, util.py:13-28).
Four
-
$\varepsilon_r[i,j]$ (er) — relative permittivity; lenses, fibers and interfaces are built by painting geometric shapes or PNG images into this array (generateEpsilon(),util.py:254). -
$\mu_r[i,j]$ (ur) — relative permeability; always$1$ in the provided scenes. -
$\sigma_x[i,j], \sigma_y[i,j]$ (sx,sy) — split conductivities. Besides the PML they are also used to build opaque/absorbing objects (apertures, screens, detector masks) by adding large values ($\sim 150$ –$550$) inside shapes. -
$w[i,j]$ (wire) — a multiplicative mask applied to the neighbor fields inside the curl terms;$w = 1$ is free space,$w = 0$ suppresses the cell's contribution to its neighbors' updates, approximating a perfect electric conductor. All provided scenes use$w \equiv 1$ .
Sources are soft (additive) point sources: a time function kernel.wgsl:103-123, half to each split component). Three kinds are implemented in generate_source() (kernel.wgsl:43-60), selected by the integer kind with parameter vector
| kind | Model | Formula | Parameters |
|---|---|---|---|
| 1 | continuous sine |
|
|
| 2 | Gaussian pulse |
|
|
| 3 | Gaussian-modulated sine (burst) |
|
Note that
| Symbol | Code variable | Meaning | Units (normalized) |
|---|---|---|---|
Ezix, Eziy (Ezx,Ezy in the notebook) |
split components of |
field units | |
Ezi (= Ezix + Eziy) |
out-of-plane electric field | field units | |
Hyi, Hxi (see note) |
in-plane magnetic field | field units | |
er |
relative permittivity | – (range 1–10 in scenes) | |
ur |
relative permeability | – (always 1) | |
sx, sy
|
split electric conductivities | 1/time | |
wire |
PEC mask | – (0 or 1) | |
dx, dy
|
grid steps (always equal) | length; default |
|
dt |
time step | time; default |
|
Nx, Ny, Nt
|
grid columns, rows, time steps | – | |
c1x, c1y
|
lossy-update decay coefficients | – | |
c2x, c2y
|
lossy-update curl coefficients | time | |
c3, c4
|
magnetic update coefficients |
time | |
t |
simulation time (GPU-side accumulator) | time | |
depth, sigma_max, m in PML()
|
PML thickness (cells), peak conductivity, grading order | –, 1/time, – |
Naming note. The kernel's arrays Hxi, Hyi correspond to Hxi is updated from the Hyi from the difference along rows (kernel.wgsl:92-98). Likewise the labels dx/dy inside the kernel are swapped with respect to row/column differences. Because the code always sets dx = dy and the media are isotropic, this relabeling has no numerical effect, but it must be kept in mind when modifying the kernel.
The equations of Section 2 are discretized with Yee's staggered leapfrog scheme [1, 2]: second-order centered differences in space and time, with leap_frog1, kernel.wgsl:63-80) are
for Hxi Hyi leap_frog2, kernel.wgsl:83-100) then use the freshly updated
for wire mask multiplies each neighbor read:
// kernel.wgsl:71-79 (leap_frog1 — E update)
let G1x = c1x[idx_row+1][idx_col+1]*Ezix_1[idx_row+1][idx_col+1];
let G4 = wire[idx_row+1][idx_col+1]*Hxi_1[idx_row+1][idx_col+1];
let G5 = wire[idx_row+1][idx_col+0]*Hxi_1[idx_row+1][idx_col+0];
Ezix_2[idx_row+1][idx_col+1] = G1x + c2x[idx_row+1][idx_col+1]*( -(G4 - G5)/dy );The conduction term
Solving for leapFrog2D_constants() (util.py:151-166):
and identically for
# util.py:153-164
ax = (1.0 - dt*sx/(2.0*er)); bx = (1.0 + dt*sx/(2.0*er))
c1x = ax/bx
c2x = (dt/er)/bx
c3 = dt/urThe 2D Yee scheme is stable when the Courant–Friedrichs–Lewy condition [4, 6] holds:
The default configuration uses half the limit — Courant number config.py:100-101):
CLF = np.sqrt(1/S['dx']**2 + 1/S['dy']**2)*2.0 # *2.0 to prevent instability in the PML borders
S['dt'] = 1.0 / CLFThe optical ckt scene (config.py:519-521) runs at the exact limit
PML() (util.py:13-28) builds the two graded conductivity maps by mirroring a 1D ramp
# util.py:23-24
sigma_x = sigma_max*sx**m # ramps in the first/last `depth` rows
sigma_y = sigma_max*sy**m # ramps in the first/last `depth` columnsCallers assign the returned pair as S['sy'], S['sx'] = PML(...) (config.py:115) — i.e. the first returned array (row-ramped) becomes
One time step (WGPUHelper.compute(), wgpu_helper.py:66-71) dispatches three compute passes in order:
-
update_source— single thread; advances$t \mathrel{+}= \Delta t$ and adds$v(t)/2$ to both split components of$E_z$ in both buffer copies (kernel.wgsl:102-123). -
leap_frog1— one thread per cell; reads$E^{(n)}$ /$H^{(n+1/2)}$ from buffer set A, writes$E^{(n+1)}$ to buffer set B. -
leap_frog2— reads the new$E^{(n+1)}$ (set B) and old$H$ (set A), writes$H^{(n+3/2)}$ to set B.
To avoid read/write data races inside a pass, every field is stored twice (wgpu_Ezix_1/_2, etc.) and two pre-built bind groups swap the read/write roles on even/odd step number n (wgpu_helper.py:330-341) — a classic ping-pong (double-buffer) scheme. Threads are launched in 2D workgroups of workgroup_size × workgroup_size (16 by default, 4 in the notebook), with 1 + Ny//wg × 1 + Nx//wg workgroups; out-of-range threads return immediately (kernel.wgsl:67-69). Grid dimensions [[N]], [[M]] and [[workgoup_size]] are placeholders substituted into the WGSL source at pipeline-build time (wgpu_helper.py:122-125), because WGSL array sizes must be compile-time constants.
Material maps are built on the CPU by painting primitives into
-
Lens (
generateEpsilon, type'lens',util.py:259-279): a biconvex lens of aperture radius$R$ and edge sagittae$r_\mathrm{left}, r_\mathrm{right}$ is the intersection of two disks; the surface-circle radii are recovered from the sagitta relation$x_c = \dfrac{R^2 - r^2}{2r}$ ,$C_r = x_c + r$ , and the region$\left{(x-x_\text{center})^2 + (y-c_y)^2 < C_r^2\right}$ is filled with$\varepsilon_r$ . -
Lens2 (
util.py:327-346): a lens defined by thickness and height, as the intersection of two circles of radius$R = \dfrac{\tau^2 + h^2}{2\tau}$ ($\tau$ =lensTickness,$h$ =lensHeight), optionally rotated byangle. -
Box, interface, image: axis-aligned rectangles; oriented half-planes
$-\left(p_x\cos a - p_y\sin a\right) < 0$ ; and grayscale PNG images (red channel, flipped vertically, scaled) placed byplaceSubImage(). -
Smoothing: edges are anti-aliased by
$n$ passes of a normalized$6\times6$ disk-shaped convolution kernel (smooth_image(),util.py:31-39), which reduces staircase scattering artifacts.
| File | Purpose |
|---|---|
main_gpu.ipynb |
Entry point (Jupyter). Selects the scene, builds initial arrays and coefficients, sets up the GPU, runs the time loop, animates |
config.py |
Scene library: configSimulation(name) dispatches to one config function per scene; each fills the dictionary S with grid, medium, PML and source definitions. |
util.py |
CPU-side numerics and helpers: PML builder, update coefficients, material rasterizers (generateEpsilon, generateConductivity), legacy CPU source generator, image smoothing, colormaps and frame composition. Defines the GPUSource NumPy dtype matching the WGSL Source struct. |
wgpu_helper.py |
WGPUHelper class: WebGPU device setup, buffer creation, shader compilation (with placeholder substitution), pipelines, double-buffer bind groups, dispatch, and host↔GPU transfer. |
kernel.wgsl |
GPU compute shaders: leap_frog1 (leap_frog2 (update_source (time accumulator and source injection), generate_source (waveform library). |
images/*.png |
Grayscale masks rasterized into |
output/ |
Manually saved animation recordings (.mp4), not written by the code. |
Data flow:
flowchart LR
A["configSimulation(name)<br/>config.py"] -->|"S: Nx,Ny,Nt,dx,dy,dt,<br/>er,ur,sx,sy,wire,sources"| B["leapFrog2D_constants()<br/>util.py"]
A -->|images/*.png| A
B -->|"c1x,c1y,c2x,c2y,c3,c4"| C["WGPUHelper.setup_compute()<br/>wgpu_helper.py"]
C -->|"buffers + pipelines"| D["GPU: kernel.wgsl<br/>update_source → leap_frog1 → leap_frog2"]
E["notebook time loop<br/>n = 1..Nt"] -->|"compute(n)"| D
D -->|"sync_gpu_host_data(): Ez"| F["addEzToComposite()<br/>composeImage(), colormaps<br/>util.py"]
F -->|RGB frame| G["matplotlib window<br/>(every 10 steps)"]
def configSimulation(name, anecroic=True)- description: Top-level scene dispatcher. Builds the default simulation dictionary via
config_default()and then applies the named scene configurator (switch-case onname). Prints a warning for unknown names and returns the default scene. - parameters:
name : str— scene identifier; one of'single lens','laser pulse','antireflex','opticalfiber','optical ckt','dual optcal fiber','refraction','wavefront sensor'(the branches'single lens plane'and'yagi_antenna'reference functions that are currently commented out and would raiseNameError).anecroic : bool— passed toconfig_default(); kept for backward compatibility with the legacy cosine absorber (the current PML ignores it).
- return:
S : dict— complete simulation description with keysNx, Ny, Nt(int),dx, dy, dt(float),er, ur, sx, sy, wire(np.ndarray (Ny,Nx) float),sources(np.ndarrayof dtypeGPUSource). - example:
S = configSimulation('refraction')builds the double-slit + prism-interface demo used in the notebook.
def config_default(anecroic=True)-
description: Creates the baseline scene:
$800\times416$ grid,$N_t = 3500$ steps,$\Delta x = \Delta y = 1/800$ ,$\Delta t$ at Courant number 0.5 (config.py:100-101), PML of 60 cells with$\sigma_{\max}=1000$ ,$m=4$ , vacuum medium (er = ur = wire = 1), no sources. -
parameters:
-
anecroic : bool— legacy switch for the disabled cosine boundary absorber; has no effect on the active PML path.
-
-
return:
S : dict— default scene (same key layout as above;sourcesmust be added by a scene function).
def config_single_lens(S)-
description: Adds one biconvex lens (
$\varepsilon_r = 3$ , heavily smoothed with 100 passes) centered at normalized$(c_x, c_y) = (1.0, 0.5)$ and one continuous sine point source (kind 1, amplitude 1.2, frequency 40) at grid point$(0.5 N_y,\ 0.2 N_x)$ . -
parameters:
-
S : dict— scene dictionary fromconfig_default(); modified in place.
-
-
return:
S : dict— updated scene.
def config_laser_pulse(S)-
description: Single Gaussian-modulated sine source (kind 3): amplitude 2.0, frequency 40, pulse center
$t_0 = 0.1$ , variance$6\times10^{-4}$ ; empty medium. Demonstrates a propagating pulse absorbed by the PML. -
parameters / return: as
config_single_lens.
def config_antireflex(S)-
description: Anti-reflection demo: an image-defined absorbing divider (
images/antireflex_s.png,$\sigma$ scale 150), four conductive boxes forming two apertures, two weak lenses ($\varepsilon_r = 1.1$ ), an image-defined crystal slab (images/antireflex_er2.png,$\varepsilon_r$ scale 1.47), and two identical sine sources (kind 1, amplitude 1.2, frequency 40) illuminating the two halves. -
parameters / return: as
config_single_lens.
def config_optical_fiber(S)-
description: Step-index fiber demo,
$N_t = 2500$ : two conductive boxes ($\sigma = 550$ ) form the entrance aperture, an image lens (images/lens.png,$\varepsilon_r$ scale 4) focuses into a rectangular core ($\varepsilon_r = 3$ ) extending to the right edge; one sine source (kind 1, amplitude 1.2, frequency 40). -
parameters / return: as
config_single_lens.
def config_optical_ckt(S)-
description: Largest scene: rebuilds the grid at
$1802\times802$ ,$N_t = 25000$ ,$\Delta t$ at the exact CFL limit, PML depth 80. Waveguide layout fromimages/waveguide.png($\varepsilon_r$ scale 17) and a curved fiber fromimages/fiber_curve.png(scale 6); aperture boxes with$\sigma = 350$ ; one sine source (kind 1, amplitude 3.0, frequency 20). -
parameters / return: as
config_single_lens(note: it replacesNx, Ny, dtand all medium arrays).
def config_dual_optical_fiber(S)-
description: Two crossing step-index fibers (
$\varepsilon_r = 2.2$ ) fed through a small lens ($\varepsilon_r = 4$ ) behind a three-box aperture ($\sigma = 550$ ); one sine source (kind 1, amplitude 1.2, frequency 40). Demonstrates evanescent coupling between cores. -
parameters / return: as
config_single_lens.
def config_refraction(S)-
description: The notebook's "mega demo",
$N_t = 6000$ : a double-aperture screen ($\sigma = 550$ ), two lenses ($\varepsilon_r = 3$ ) collimating the beam, then a$45°$ tilted half-plane interface raising$\varepsilon_r$ by a factor 10 and a second tilted interface restoring it (Section 3.6, type'interface'). Two co-located sine sources of different frequencies (kind 1: amplitudes 2.2 and 1.2, frequencies 40 and 40 — the comments suggest 10 and 80 for a "rainbow" dispersion variant). Demonstrates Snell refraction of the beam. -
parameters / return: as
config_single_lens.
def wavefront_sensor(S)-
description: Shack–Hartmann-style wavefront sensor: a column of five plano-convex lenslets (
'lens2',$\varepsilon_r = 2$ ) held by conductive support boxes ($\sigma = 550$ ) at$x = 1.6$ , an image-defined detector screen (images/detector.png,$\sigma$ scale 550) and an atmospheric phase screen (images/atmosfere2.png,$\varepsilon_r$ scale 2). Grid rescaled by factorsc = 0.95($760\times395$ ). One sine source (kind 1, amplitude 0.8, frequency 80). The dictionary is mutated in place;configSimulationignores its return value, which works becausedictis mutable. -
parameters:
-
S : dict— scene dictionary; rebuilt in place (grid, PML, medium and sources are all replaced).
-
-
return:
S : dict— the same dictionary.
GPUSource = np.dtype([('position', np.int32, (2,)), ('kind', np.int32),
('_', np.int32), ('parameters', np.float32, (4,))])- description: NumPy structured dtype mirroring the WGSL
struct Sourcebyte layout (kernel.wgsl:13-18):vec2<i32>position (row, column),i32kind, onei32of explicit padding soparameterslands on a 16-byte boundary,vec4<f32>parameters. Total 24 bytes → padded struct compatible with the storage buffer.
def PML(Nx, Ny, depth, sigma_max, m)-
description: Builds the two polynomial-graded PML conductivity maps of Section 3.4:
$\sigma(u) = \sigma_{\max}(u/d)^m$ ramping from 0 at the inner PML edge to$\sigma_{\max}$ at the outer grid edge, in all four boundary strips. -
parameters:
-
Nx : int— grid columns. -
Ny : int— grid rows. -
depth : int— PML thickness$d$ in cells (60–80 in the scenes). -
sigma_max : float— peak conductivity$\sigma_{\max}$ (1000 in the scenes), normalized units. -
m : int— grading polynomial order$m$ (4 in the scenes; 3–4 is the standard recommendation [2]).
-
-
return:
(sigma_x, sigma_y) : tuple of np.ndarray (Ny,Nx)— first array ramps in the first/lastdepthrows (absorbs at top/bottom), second in the first/lastdepthcolumns (left/right). Callers assign them asS['sy'], S['sx'] = PML(...). -
example:
sy, sx = PML(800, 416, 60, 1000, 4).
def smooth_image(I, n)-
description: Applies
npasses of a normalized$6\times6$ disk-shaped moving-average convolution (viascipy.signal.convolve2d,'same'mode) to soften material edges and reduce staircase diffraction artifacts. -
parameters:
-
I : np.ndarray (Ny,Nx)— image/material map. -
n : int— number of smoothing passes (0 = no-op).
-
-
return:
np.ndarray (Ny,Nx) float— smoothed map.
def fastInd2RGB(Iidx, M)-
description: Vectorized indexed-color lookup: clips an index image to
$[0,255]$ and maps each pixel through a$255\times3$ colormap by flat fancy-indexing. Note the cast is tonp.int8(signed); values above 127 wrap negative and index the LUT from its end, which the arctan-saturated colormaps tolerate visually. -
parameters:
-
Iidx : np.ndarray (Ny,Nx)— scalar index image. -
M : np.ndarray (255,3) float— RGB colormap in$[0,1]$ .
-
-
return:
np.ndarray (Ny,Nx,3) float— RGB image.
def addEzToComposite(Ii, Ez, M)-
description: Produces one animation frame: rescales the field as
$E_s = 40 E_z + 130$ (bias 130 ≈ mid-LUT for$E_z=0$ ), maps it through colormapM, and adds it to the static material imageIiwith clipping to$[0,255]$ . -
parameters:
-
Ii : np.ndarray (Ny,Nx,3) uint8— precomputed material composite (fromcomposeImage). -
Ez : np.ndarray (Ny,Nx) float— field snapshot (the notebook passes100.0*Ezi). -
M : np.ndarray (255,3) float— diverging colormap (M1: red for negative, blue for positive).
-
-
return:
np.ndarray (Ny,Nx,3) uint8— displayable RGB frame. -
example:
I = addEzToComposite(Imaterial, 100.0*Ezi, M1)(notebook display cell).
def composeImage(s, wire, er, M2, M3, M4)-
description: Renders the static material overlay shown under the field: conductivity as
$s/2 + 128$ throughM2(yellow/blue), wire mask as$255(1-w)$ throughM3(grayscale), permittivity as$255(\varepsilon_r - 1)/2$ throughM4(cyan); averages and clips the three layers. -
parameters:
-
s : np.ndarray (Ny,Nx)— conductivity map to display (the notebook passes(sx+sy)/2). -
wire : np.ndarray (Ny,Nx)— PEC mask. -
er : np.ndarray (Ny,Nx)— relative permittivity. -
M2, M3, M4 : np.ndarray (255,3)— colormaps fromcustomColormaps().
-
-
return:
np.ndarray (Ny,Nx,3) uint8— material image.
def customColormaps()-
description: Builds four 255-entry RGB lookup tables from arctangent sigmoids
$\dfrac{\pi/2 + \arctan\left(\beta, (i - i_0)/255\right)}{\pi}$ with sharpness$\beta = 8$ and pivots$i_0 \in {60, 128, 196}$ , then normalizes each by its global maximum (MATLAB-style).M1: field colormap (red↔blue diverging);M2: conductivity (blue→yellow);M3: grayscale;M4: permittivity (black→cyan). - parameters: none.
-
return:
(M1, M2, M3, M4) : tuple of np.ndarray (255,3) float— colormaps in$[0,1]$ .
def placeSubImage(Nx, Ny, cx, cy, Ii)-
description: Pastes a (vertically flipped) sub-image centered at pixel
$(c_x, c_y)$ into a zero background of size$N_y\times N_x$ . If the sub-image would overflow any border, prints'Image place forbidden'and returns an empty array. -
parameters:
-
Nx, Ny : int— target grid size. -
cx, cy : float— center in pixels (callers passcx*Nx,cy*Ny). -
Ii : np.ndarray (Hi,Wi)— sub-image to place.
-
-
return:
np.ndarray (Ny,Nx) float— background with the sub-image inserted (or empty array on overflow).
def leapFrog2D_constants(er, ur, sx, sy, dt)-
description: Precomputes the update coefficients of Section 3.2 from the material maps:
$c_{1x,y}$ (decay),$c_{2x,y}$ (curl gain),$c_3 = c_4 = \Delta t/\mu_r$ . This is where$\varepsilon_r$ ,$\sigma$ and$\Delta t$ enter the GPU scheme; the kernel itself only sees these six arrays. -
parameters:
-
er, ur : np.ndarray (Ny,Nx)— relative permittivity/permeability. -
sx, sy : np.ndarray (Ny,Nx)— split conductivities (normalized). -
dt : float or np.ndarray shape (1,)— time step.
-
-
return:
(c1x, c1y, c2x, c2y, c3, c4) : tuple of np.ndarray (Ny,Nx)— see the symbol table (Section 2.5). -
example:
c1x, c1y, c2x, c2y, c3, c4 = leapFrog2D_constants(er, ur, sx, sy, np.array([dt])).
def generateConductivity(Nx, Ny, cond_type, parameters)-
description: Rasterizes a conductivity map
$\sigma[i,j]$ of one of three types.'boundary': legacy cosine-taper frame absorber (superseded byPML());'image': grayscale PNG (red channel, flipped, resized byimScale, smoothed) scaled toscaleand placed at$(c_x N_x, c_y N_y)$ ;'box': axis-aligned rectangle of valuevaluewith optional smoothing. Box coordinates are normalized to the domain height ($\times N_y$ ) for both axes, so$x \in [0, N_x/N_y]$ (≈$[0,1.92]$ on the default grid). -
parameters:
-
Nx, Ny : int— grid size. -
cond_type : str—'boundary' | 'image' | 'box'. -
parameters : dict— per type: boundary:L, maxS, value; image:name(path),cx, cy(normalized center),scale(σ of a white pixel),imScale(image resize factor), optionalsmooth; box:upper_left_x/y, lower_right_x/y(normalized by$N_y$ ),value, optionalsmooth(passes).
-
-
return:
np.ndarray (Ny,Nx) float— conductivity contribution (callers add it toS['sx']/S['sy']). -
example:
S['sx'] += generateConductivity(800, 416, 'box', {'upper_left_x':0.5, 'upper_left_y':0.01, 'lower_right_x':0.57, 'lower_right_y':0.42, 'value':550, 'smooth':10})builds the upper aperture blade of the refraction scene.
def generateEpsilon(Nx, Ny, eps_type, parameters)-
description: Rasterizes a relative-permittivity map. Types:
'lens'(biconvex lens from two-disk intersection, Section 3.6; background 1, lens bodyer);'image'(PNG placed as$1 + \text{scale}\cdot I/255$ );'box'(rectangle ofvalueon a background of 1);'interface'(tilted half-plane step of heightamplitudeplus 1);'lens2'(thickness/height-defined lens, background 0 — callers add it). Several other types ('plane_x','percolation') exist only as commented-out MATLAB legacy. -
parameters:
-
Nx, Ny : int— grid size. -
eps_type : str—'lens' | 'image' | 'box' | 'interface' | 'lens2'. -
parameters : dict— per type: lens:cx, cy, r_left, r_right, R, er, optionalsmooth; image:name, cx, cy, scale, imScale, optionalsmooth; box: corner keys as ingenerateConductivityplusvalue; interface:point_x, point_y, angle(rad),amplitude, optionalsmooth; lens2:cx, cy, lensTickness, lensHeight, angle, er, optionalsmooth. All coordinates normalized by$N_y$ .
-
-
return:
np.ndarray (Ny,Nx) float— permittivity map (multiplied, added or assigned by the caller depending on the scene). -
example:
er = generateEpsilon(800, 416, 'lens', {'cx':1.0,'cy':0.5,'r_left':0.15,'r_right':0.15,'R':0.5,'er':3,'smooth':100}).
def generateSource(Nx, Ny, source_type, parameters)-
description: Legacy CPU source generator (unused in the GPU pipeline, kept from the pre-GPU version): returns a full grid with the source value written at one point (or one column for the plane types), to be added to the field each step. Types:
'pulse','burst','sine','plain_square_x','plain_sine_x'; several more remain as commented MATLAB code. In the current program sources are instead injected on the GPU (update_source,kernel.wgsl:102). -
parameters:
-
Nx, Ny : int— grid size. -
source_type : str— waveform/geometry type. -
parameters : dict—cx, cy(normalized by$N_y$ ),amplitude,time, and per typefrequency,t0,width(std-dev),offset(row margin for the plane types).
-
-
return:
np.ndarray (Ny,Nx) float— field increment for the current time.
def __init__(self, workgroup_size=16, shader_source='kernel.wgsl')- description: Stores configuration and initializes all host- and GPU-side field references to
None. No GPU work happens here. - parameters:
workgroup_size : int— side of the square GPU workgroup (threads per group =workgroup_size**2; the notebook uses 4). Must divide the hardware limit (typically ≤ 256 threads/group).shader_source : str— path to the WGSL kernel file.
- return:
WGPUHelperinstance (constructor).
def setup_compute(self, c1x, c1y, c2x, c2y, c3, c4, dx, dy, dt, t, wire,
Ezix, Eziy, Hxi, Hyi, sources)- description: One-time GPU initialization: (1) casts and stores all arrays as
float32copies; (2) requests a high-performance WebGPU adapter and device, printing the adapter summary; (3) loadskernel.wgsl, substitutes the[[N]],[[M]],[[workgoup_size]]placeholders with the actual grid/workgroup sizes and compiles the shader module; (4) creates one storage buffer per array (coefficients, fields ×2 for double buffering,t,sources) and uniform buffers fordx, dy, dt; (5) builds three compute pipelines (leap_frog1,leap_frog2,update_source) over a shared 20-binding layout; (6) builds the two ping-pong bind groups that swap the_1/_2field buffers (Section 3.5). - parameters:
c1x … c4 : np.ndarray (Ny,Nx)— update coefficients fromleapFrog2D_constants().dx, dy, dt, t : np.ndarray shape (1,) float— grid steps, time step, initial time (scalars wrapped in arrays so they map to GPU buffers).wire : np.ndarray (Ny,Nx)— PEC mask.Ezix, Eziy, Hxi, Hyi : np.ndarray (Ny,Nx)— initial fields (normally zeros).sources : np.ndarray dtype=GPUSource— source table (uploaded via.tobytes()).
- return:
None(state stored on the instance). - example:
wgpu_helper.setup_compute(c1x, c1y, c2x, c2y, c3, c4, dx, dy, dt, t, wire, Ezx, Ezy, Hx, Hy, S['sources'])(notebook cell).
def compute(self, n)- description: Advances the simulation one full time step by dispatching, in order:
update_source(1 thread),leap_frog1(E update, one thread/cell),leap_frog2(H update). The step paritynselects which bind group (buffer set) is read vs. written. - parameters:
n : int— current step number (controls ping-pong parity; the source pass is always dispatched with parity 0 because it writes both buffer sets anyway).
- return:
None(GPU state advanced; work is submitted asynchronously to the queue).
def call_kernel(self, N_workers, M_workers, n, entry_point_idx)-
description: Low-level dispatch: creates a command encoder and compute pass, selects pipeline
entry_point_idx(0 =leap_frog1, 1 =leap_frog2, 2 =update_source), binds bind group 1 for evenn/ 2 for oddn, and dispatches(1 + N_workers//wg, 1 + M_workers//wg)workgroups. -
parameters:
-
N_workers : int— number of rows to cover ($N_y$ ). -
M_workers : int— number of columns to cover ($N_x$ ). -
n : int— step parity selector. -
entry_point_idx : int— pipeline index (see above).
-
-
return:
None.
def sync_gpu_host_data(self)-
description: Blocking read-back of the two split-field buffers (
wgpu_Ezix_1,wgpu_Eziy_1), reshaped to the grid shape and summed to reconstruct$E_z = E_{zx} + E_{zy}$ . Only the display path uses this;$H$ is never read back. Note it always reads buffer set 1, so on even steps the returned field is one step behind the freshest set (irrelevant for visualization). - parameters: none.
-
return:
np.ndarray (Ny,Nx) float32— current$E_z$ field.
def sync_host_gpu_data(self)- description: Uploads every host-side array (coefficients,
dx, dy, dt, t,wire, and both copies of all four fields) to its GPU buffer viaqueue.write_buffer. Called once aftersetup_compute(); can be reused to hot-reset the simulation state. - parameters: none.
- return:
None.
fn generate_source(kind : i32, parameters : vec4<f32>, t : f32) -> f32-
description: Waveform library evaluating
$v(t)$ for the three source kinds of Section 2.4; returns0.0for unknown kinds. -
parameters:
-
kind : i32— 1 sine, 2 Gaussian, 3 Gaussian-modulated sine. -
parameters : vec4<f32>—$(p_1, p_2, p_3, p_4)$ as in the table of Section 2.4 (unused slots areNaNin the configs and never read). -
t : f32— current simulation time.
-
-
return:
f32— source amplitude$v(t)$ .
@compute fn leap_frog1(@builtin(global_invocation_id) index: vec3<u32>)-
description: Electric-field update (first leapfrog half, Section 3.1). Thread
$(\texttt{index.x}, \texttt{index.y}) = (i,j)$ updates cell$[i{+}1, j{+}1]$ ofEzix_2/Eziy_2fromEzix_1/Eziy_1and thewire-masked magnetic neighbors inHxi_1/Hyi_1. Threads with$i \ge N-1$ or$j \ge M-1$ return immediately (boundary guard). -
parameters:
index : vec3<u32>— global thread id (.x= row,.y= column). - return: none (writes storage buffers at bindings 15–16).
@compute fn leap_frog2(@builtin(global_invocation_id) index: vec3<u32>)-
description: Magnetic-field update (second leapfrog half). Reconstructs
$E_z = E_{zx}+E_{zy}$ from the freshly written set-2 buffers (masked bywire) and incrementsHxi_2/Hyi_2fromHxi_1/Hyi_1with the centered differences of Section 3.1. (K4recomputes the same value asK1; redundant but harmless.) -
parameters / return: as
leap_frog1(writes bindings 17–18).
@compute fn update_source(@builtin(global_invocation_id) index: vec3<u32>)-
description: Single-thread pass run once per step: advances the GPU-resident clock
t += dt, then loops over the source table and adds$v(t)/2$ toEzixandEziyat each source's grid point in both buffer sets (so the injection survives the ping-pong swap). The loop bound is hard-coded to 2 sources (for (var i=0; i<2; i++),kernel.wgsl:110); see Section 9. -
parameters:
index : vec3<u32>— thread id (all but thread (0,0) return). - return: none (writes bindings 10–12, 15–16).
-
Scene name — string passed to
configSimulation()in the notebook's third cell; the active scene is chosen by (un)commenting lines. Available names and what they demonstrate:Name Demo Notes 'laser pulse'Gaussian burst in vacuum shortest run 'single lens'point source focused by one lens 'antireflex'anti-reflection coating comparison uses 2 PNG masks 'opticalfiber'step-index fiber coupling $N_t = 2500$ 'optical ckt'integrated optical circuit $1802\times802$ grid,$N_t = 25000$ , longest'dual optcal fiber'coupled fibers (sic — note the typo in the key) 'refraction'beam refraction at tilted interfaces notebook default, $N_t = 6000$ 'wavefront sensor'Shack–Hartmann lenslet array + atmosphere uses 2 PNG masks 'single lens plane','yagi_antenna'— broken: their config functions are commented out ( config.py:213,config.py:310) -
PNG masks (
images/*.png) — grayscale images whose red channel is rasterized into$\varepsilon_r$ or$\sigma$ (Section 3.6). White (255) maps to the fullscalevalue, black to 0. They are flipped vertically on load. -
Scene dictionary
S— all physical/numerical parameters (see Sections 2.5 and 5.1). No CLI arguments and no input data files other than the PNGs.
-
Live animation (matplotlib Qt window): every 10 steps the frame
$I = \mathrm{clip}\left(255,\mathrm{LUT}!\left(40\cdot 100,E_z + 130\right) + I_\text{material}\right)$ is drawn — red/blue = negative/positive$E_z$ , yellow = conductive objects, cyan = dielectric objects (Section 5.2). Nothing is written to disk. -
Console: GPU adapter info at setup; total elapsed wall time at the end; optional per-stage timings when
show_time = Truein the main loop cell. -
output/*.mp4— pre-recorded screen captures of past runs, not produced by the code.
| Dependency | Used for | Import site |
|---|---|---|
| Python 3 (≥3.9 recommended) | host language | – |
numpy |
arrays, dtypes, coefficients | all .py files |
scipy |
signal.convolve2d (smoothing) |
util.py:3 |
matplotlib |
animation window | notebook, util.py:2 |
Pillow (PIL) |
PNG loading/resizing | util.py:4 |
wgpu (wgpu-py [8]) |
WebGPU device, buffers, pipelines | wgpu_helper.py:1 |
rendercanvas |
imported by wgpu_helper.py:3 (unused in the compute path but required for the import to succeed) |
wgpu_helper.py:3 |
PyQt5 (or another Qt binding) |
%matplotlib qt5 backend |
notebook cell 2 |
| Jupyter | running main_gpu.ipynb |
– |
No versions are pinned anywhere in the repository (no requirements.txt). The program is OS-agnostic wherever wgpu-py finds a WebGPU backend (Metal on macOS — the development platform, Vulkan on Linux, D3D12/Vulkan on Windows). No compilation step: the WGSL shader is compiled at runtime by the wgpu driver.
Install:
pip install numpy scipy matplotlib pillow wgpu rendercanvas pyqt5 jupyter-
Open the notebook:
cd "python/NOTEBOKS/FD-TD" jupyter notebook main_gpu.ipynb
-
Run the cells in order:
- Cell 1 — imports.
- Cell 2 —
%matplotlib qt5(pops the animation into a native window; replace withqtor another interactive backend if PyQt5 is unavailable). - Cell 3 — scene selection: uncomment exactly one
S = configSimulation(...)line (default:'refraction'); the same cell zeroes the fields and computes the update coefficients. - Cell 4 (
WGPUHelpercell) —wgpu_helper = WGPUHelper(workgroup_size=4),setup_compute(...),sync_host_gpu_data(). Prints the selected GPU adapter. - Last cell — builds the colormaps and material composite, then runs the
while n < Ntloop, drawing every 10th frame.
-
Minimal working example (script form, no notebook):
from wgpu_helper import * from config import * from util import * S = configSimulation('laser pulse') Z = lambda: np.zeros((S['Ny'], S['Nx'])) c = leapFrog2D_constants(S['er'], S['ur'], S['sx'], S['sy'], np.array([S['dt']])) h = WGPUHelper(workgroup_size=4) h.setup_compute(*c, np.array([S['dx']]), np.array([S['dy']]), np.array([S['dt']]), np.array([0.0]), S['wire'], Z(), Z(), Z(), Z(), S['sources']) h.sync_host_gpu_data() for n in range(1, S['Nt'] + 1): h.compute(n) Ez = h.sync_gpu_host_data() # final field, shape (Ny, Nx)
Expected runtime: on the development machine (Apple-silicon Mac, Metal backend) the default scenes ('optical ckt' (sync_gpu_host_data() is called every iteration, not only on drawing steps) is a major part of the cost.
Qualitative validation cases built into the scenes: PML absorption ('laser pulse' — the pulse should vanish at the borders without visible reflection), focusing consistent with the lensmaker geometry ('single lens'), Snell-law beam deviation at the 'refraction'), total internal reflection guiding ('opticalfiber'). No quantitative regression tests exist.
Known pitfalls and limitations found in the code:
-
Hard-coded source count.
update_sourceloops over exactly 2 sources (kernel.wgsl:110). Scenes with one source (e.g.'single lens','laser pulse','wavefront sensor') readsources[1]out of bounds; WGSL robust-buffer-access clamps the index, so the single source is effectively injected twice (doubled amplitude). Scenes with more than 2 sources would silently ignore the extras. The loop bound should be the source-array length (e.g. viaarrayLength). -
Broken scene names.
'single lens plane'and'yagi_antenna'dispatch to functions that are commented out and raiseNameError(config.py:50-57). -
CFL safety factor. The default
$\Delta t$ uses Courant number 0.5 explicitly to keep the strong PML stable (config.py:100);'optical ckt'runs at Courant number 1.0 (config.py:519), the exact 2D stability limit — any added lossy structure there operates with no margin. -
Smoothing vs. background. As warned in
config.py:35, smoothing$\varepsilon_r$ maps is dangerous because the background is 1, not 0; the disk kernel bleeds the background into objects and vice versa. Scene functions therefore combine maps carefully with+ ... - 1and*patterns. -
PEC mask is approximate.
wiremasks neighbor contributions inside the curls but does not zero the field storage itself; it is adequate for visualization, not a rigorous PEC boundary. -
No magnetic PML conductivity.
$\sigma^{(*)} = 0$ (Section 2.2), so the layer is not discretely matched; residual reflection is suppressed by the deep ($60$ –$80$ cell), high-order ($m=4$ ) grading. -
fastInd2RGBsigned cast. Thenp.int8cast (util.py:47) wraps indices above 127; visually masked by the saturated colormaps but incorrect as written (should benp.uint8). -
Display latency.
sync_gpu_host_data()always reads buffer set 1 (wgpu_helper.py:346-347), which on even steps is one time level stale, and it is called every step even when no frame is drawn — a straightforward performance optimization target. -
Normalized units only. Mapping to physical problems requires rescaling lengths by the chosen domain size
$L_0$ and times by$L_0/c$ .
[1] K. S. Yee, "Numerical solution of initial boundary value problems involving Maxwell's equations in isotropic media," IEEE Transactions on Antennas and Propagation, vol. 14, no. 3, pp. 302–307, 1966. DOI: 10.1109/TAP.1966.1138693.
[2] A. Taflove and S. C. Hagness, Computational Electrodynamics: The Finite-Difference Time-Domain Method, 3rd ed., Artech House, Norwood, MA, 2005.
[3] J.-P. Bérenger, "A perfectly matched layer for the absorption of electromagnetic waves," Journal of Computational Physics, vol. 114, no. 2, pp. 185–200, 1994. DOI: 10.1006/jcph.1994.1159.
[4] R. Courant, K. Friedrichs, and H. Lewy, "Über die partiellen Differenzengleichungen der mathematischen Physik," Mathematische Annalen, vol. 100, pp. 32–74, 1928. DOI: 10.1007/BF01448839.
[5] J. C. Maxwell, "A dynamical theory of the electromagnetic field," Philosophical Transactions of the Royal Society of London, vol. 155, pp. 459–512, 1865. DOI: 10.1098/rstl.1865.0008.
[6] A. Taflove and M. E. Brodwin, "Numerical solution of steady-state electromagnetic scattering problems using the time-dependent Maxwell's equations," IEEE Transactions on Microwave Theory and Techniques, vol. 23, no. 8, pp. 623–630, 1975. DOI: 10.1109/TMTT.1975.1128640.
[7] W. H. Press, S. A. Teukolsky, W. T. Vetterling, and B. P. Flannery, Numerical Recipes: The Art of Scientific Computing, 3rd ed., Cambridge University Press, 2007. (General background on finite-difference schemes and stability.)
[8] wgpu-py developers, wgpu-py: WebGPU for Python, https://wgpu-py.readthedocs.io (accessed 2026-07-11); W3C, WebGPU Shading Language (WGSL) Specification, https://www.w3.org/TR/WGSL/.
No authoritative published reference was found for the arctangent-sigmoid colormap construction (customColormaps(), util.py:87) or for the two-disk lens rasterization; both appear to be original constructions of the author.