Chapter 33
28 min read
Section 281 of 353

Poisson's Equation in Electrostatics

Poisson's Equation

The Big Picture

Take a region of empty space and sprinkle some electric charge into it. Two questions immediately arise: where is the voltage high, where is it low, and which way will a test charge accelerate? Both questions are answered by a single scalar field, the electric potential V(r)V(\mathbf{r}). And that potential is locked to the charge distribution by exactly one equation, Poisson's equation:

2V  =  ρ(r)ε0\displaystyle \nabla^2 V \;=\; -\,\frac{\rho(\mathbf{r})}{\varepsilon_0}

Here ρ(r)\rho(\mathbf{r}) is the charge per unit volume, ε08.854×1012F/m\varepsilon_0 \approx 8.854 \times 10^{-12}\,\mathrm{F/m} is the permittivity of free space, and 2\nabla^2 is the Laplacian. Read it left-to-right: tell me how the potential curves at a point, and I'll tell you how much charge is sitting there. Read it right-to-left: tell me where the charges are, and I'll tell you how the potential bends.

Why this equation is so powerful. All of static electricity — every capacitor, every semiconductor device, every lightning rod, every MRI gradient coil — is governed by this single PDE. Solve it once for your geometry, and you know the voltage everywhere, the field everywhere (E=V\mathbf{E} = -\nabla V), and the energy stored in the system. There is no "step 2."

The Curvature-Equals-Source Intuition

Forget the Greek letters for a moment. The Laplacian of a function at a point compares the function's value there to the average of its immediate neighbours:

2V(r)    VnearbyV(r)\nabla^2 V(\mathbf{r}) \;\propto\; \big\langle V \big\rangle_{\text{nearby}} - V(\mathbf{r})

If a point is exactly at the average of its surroundings, 2V=0\nabla^2 V = 0. If it's a peak (higher than average), the Laplacian is negative. If it's a valley, the Laplacian is positive. Now look at Poisson:

2V  =  ρ/ε0\nabla^2 V \;=\; -\,\rho/\varepsilon_0

Wherever there is positive charge (ρ>0\rho > 0), the right-hand side is negative, so the left-hand side is negative — the potential at that point sits above the average of its neighbours. Positive charge pushes the voltage up locally; negative charge pulls it down. No charge? Then the potential is exactly the average of its surroundings (this is Laplace's equation, and the "mean value property" of harmonic functions).

Analogy 1 — the rubber sheet. Imagine a tight rubber sheet. Now press your finger up from below at some location. The sheet bulges up there and gently flattens far away. That bulge is the potential VV; your finger is the positive charge. Press from above and you get a dip — that's negative charge. Multiple fingers? Their bulges and dips superpose linearly (because Poisson is linear).
Analogy 2 — temperature with heaters. Replace voltage by temperature and charge by heat sources. In steady state, the temperature distribution in a slab satisfies 2T=q/k\nabla^2 T = -q/k: heat sources push the temperature above the local average; cold spots are sinks. Exactly the same equation, different letters. This is why the same numerical solver works for both physics problems.

From Gauss's Law to Poisson's Equation

Poisson's equation isn't pulled out of a hat — it is a one-line consequence of Gauss's law plus the definition of the potential. The chain is short enough to memorize.

  1. Gauss's law (differential form). The divergence of the electric field equals the charge density divided by ε0\varepsilon_0: E=ρ/ε0\nabla \cdot \mathbf{E} = \rho/\varepsilon_0.
  2. Electric field from a potential. In electrostatics there are no time-varying magnetic fields, so the field is curl-free, which means it can be written as the gradient of a scalar: E=V\mathbf{E} = -\nabla V. The minus sign is convention so that E\mathbf{E} points from high voltage to low voltage (positive test charges roll downhill in VV).
  3. Substitute step 2 into step 1. E=(V)=2V\nabla \cdot \mathbf{E} = \nabla \cdot (-\nabla V) = -\nabla^2 V. Setting that equal to ρ/ε0\rho/\varepsilon_0 and flipping signs gives the result:
  2V  =  ρ/ε0  \boxed{\;\nabla^2 V \;=\; -\,\rho/\varepsilon_0\;}

Notice what got hidden: the operator \nabla \cdot \nabla is the Laplacian. In Cartesian coordinates it expands to second partial derivatives:

2V=2Vx2+2Vy2+2Vz2\nabla^2 V = \frac{\partial^2 V}{\partial x^2} + \frac{\partial^2 V}{\partial y^2} + \frac{\partial^2 V}{\partial z^2}

The reason every interesting electrostatics problem boils down to second derivatives is precisely this substitution chain. The field is a gradient; the source rule is on the divergence; chained together, you get a second-order operator on the potential.


Boundary Conditions Make It Concrete

Poisson's equation by itself has infinitely many solutions: you can add any harmonic function (a solution to 2U=0\nabla^2 U = 0) to a particular solution and get another valid one. What pins down the unique physical answer is the boundary data:

TypeWhat you specifyPhysical situation
DirichletV = V_0 on the boundaryConductor held at fixed voltage (battery terminal, ground plane)
Neumann\partial V/\partial n = E_n on the boundarySpecified surface charge on a conductor (insulated or biased)
Robin (mixed)a V + b \,\partial V/\partial n = cImperfect contact, leakage, lossy coatings
Practical rule of thumb. Inside any solid conductor in electrostatic equilibrium, VV is constant. This means the surface of every conductor in your problem becomes a Dirichlet boundary at the conductor's voltage. Most engineering problems reduce to: "solve Poisson in the air/insulator region, with the metal boundaries held at known voltages."

Worked Example: Uniformly Charged Sphere

This single example unlocks an entire class of problems. Take a ball of radius RR with total charge QQ spread uniformly through its volume. By spherical symmetry, the potential VV depends only on the distance rr from the centre, so the Laplacian in spherical coordinates collapses to:

2V  =  1r2ddr ⁣(r2dVdr)\nabla^2 V \;=\; \frac{1}{r^2}\,\frac{d}{dr}\!\left(r^2\,\frac{dV}{dr}\right)

Inside the sphere the charge density is the constant ρ0=Q/(43πR3)\rho_0 = Q/(\tfrac{4}{3}\pi R^3). Outside there is no charge, so Poisson reduces to Laplace. We solve the two regions separately and glue them together at r=Rr = R by demanding VV and dV/drdV/dr match (no surface charge at the boundary, no sudden jump in field).

Uniformly charged sphere: V(r)V(r)

Inside the sphere (r<R)(r < R) the potential is parabolic; outside it falls as 1/r1/r. The yellow band marks the sphere's interior.

After integrating twice in each region and applying the matching conditions plus the physical requirement V()=0V(\infty) = 0, you get the famous piecewise answer:

V(r)  =  {Q4πε03R2r22R3,rRQ4πε0r,r>RV(r) \;=\; \begin{cases} \dfrac{Q}{4\pi\varepsilon_0}\,\dfrac{3R^2 - r^2}{2R^3}, & r \le R \\[6pt] \dfrac{Q}{4\pi\varepsilon_0\,r}, & r > R \end{cases}

Three things to notice in the visualization above:

  • Outside the sphere, the answer is identical to a point charge sitting at the centre. The sphere's extended structure is invisible from outside — Newton's "shell theorem," rediscovered electrically.
  • Inside, the potential is a downward-opening parabola in rr, peaking at the centre. The electric field E=dV/dr=(Q/4πε0)r/R3E = -dV/dr = (Q/4\pi\varepsilon_0)\,r/R^3 grows linearly from zero at the centre to its maximum at the surface.
  • At the surface (r=Rr = R), both pieces give V(R)=Q/(4πε0R)V(R) = Q/(4\pi\varepsilon_0 R) and both derivatives match — that's the gluing condition working.
Expand: do the integration by hand (try it yourself first!)

Step 1 — write the ODE in each region. Spherical Laplacian on a radial function: 1r2(r2V)=ρ/ε0\dfrac{1}{r^2}(r^2 V')' = -\rho/\varepsilon_0. Multiply by r2r^2: (r2V)=ρr2/ε0(r^2 V')' = -\rho r^2/\varepsilon_0. Now we just antidifferentiate twice.

Step 2 — inside (r<Rr < R), constant density ρ0\rho_0.

  1. Integrate once: r2V=ρ0r3/(3ε0)+C1r^2 V' = -\rho_0 r^3/(3\varepsilon_0) + C_1. For VV to be finite (and VV' to be zero) at the centre, we need C1=0C_1 = 0. So V=ρ0r/(3ε0)V' = -\rho_0 r/(3\varepsilon_0).
  2. Integrate again: Vin(r)=ρ0r2/(6ε0)+C2V_{\text{in}}(r) = -\rho_0 r^2/(6\varepsilon_0) + C_2. The constant C2C_2 will be fixed by the matching at r=Rr=R.

Step 3 — outside (r>Rr > R), zero density.

  1. Integrate twice with ρ=0\rho = 0: Vout(r)=A/r+BV_{\text{out}}(r) = -A/r + B. Demanding V()=0V(\infty) = 0 kills BB. So Vout(r)=A/rV_{\text{out}}(r) = A/r with AA still to find.

Step 4 — match at r=Rr = R.

  1. Match values: ρ0R2/(6ε0)+C2=A/R-\rho_0 R^2/(6\varepsilon_0) + C_2 = A/R.
  2. Match slopes: Vin(R)=ρ0R/(3ε0)V'_{\text{in}}(R) = -\rho_0 R/(3\varepsilon_0) and Vout(R)=A/R2V'_{\text{out}}(R) = -A/R^2. Setting equal: A=ρ0R3/(3ε0)A = \rho_0 R^3/(3\varepsilon_0).
  3. Use ρ0=Q/(43πR3)\rho_0 = Q/(\tfrac{4}{3}\pi R^3) to simplify A=Q/(4πε0)A = Q/(4\pi\varepsilon_0). Plug back into the value-match to get C2=Q/(4πε0)3/(2R)C_2 = Q/(4\pi\varepsilon_0)\cdot 3/(2R).

Step 5 — assemble. Substituting back:

Vin(r)=Q4πε03R2r22R3,Vout(r)=Q4πε0r.V_{\text{in}}(r) = \frac{Q}{4\pi\varepsilon_0}\,\frac{3R^2 - r^2}{2R^3}, \qquad V_{\text{out}}(r) = \frac{Q}{4\pi\varepsilon_0 r}.

Sanity check with numbers. Take Q=1nCQ = 1\,\text{nC}, R=10cmR = 10\,\text{cm}. Using 1/(4πε0)8.988×109Nm2/C21/(4\pi\varepsilon_0) \approx 8.988 \times 10^9\,\text{N}\,\text{m}^2/\text{C}^2:

  • At centre: V(0)=3kQ/(2R)=38.988 ⁣× ⁣109109/(20.1)134.82VV(0) = 3kQ/(2R) = 3\cdot 8.988\!\times\!10^9\cdot 10^{-9}/(2\cdot 0.1) \approx 134.82\,\text{V}.
  • At surface: V(R)=kQ/R89.88VV(R) = kQ/R \approx 89.88\,\text{V}.
  • At twice the radius: V(2R)=kQ/(2R)44.94VV(2R) = kQ/(2R) \approx 44.94\,\text{V}. Note V(R)/V(2R)=2V(R)/V(2R) = 2 exactly — outside, the potential is the point-charge potential, so it drops as 1/r1/r.

Interactive: Charges → Potential Field

Reading the equation is one thing; seeing the potential field rearrange itself as you drop charges is something else. The widget below solves the 2D Poisson equation on a 64×64 grid in real time using Jacobi relaxation (the same algorithm you'll see in Python next). Click anywhere to drop a charge; toggle between positive and negative; watch the equipotential contours and the field arrows reorganize themselves around every new source.

Click anywhere to place charges

The grid solves 2V=ρ/ε0\nabla^2 V = -\rho/\varepsilon_0 in real time using Jacobi relaxation. Red = high potential, blue = low. Arrows show E=V\mathbf{E} = -\nabla V; gray dots mark equipotential contours.

2 charges placed.
What to play with. Place two charges far apart — see how their influences add (superposition). Place +/- close together — that's a dipole, and the field becomes strongly directional. Place a ring of positive charges — the interior becomes nearly equipotential (a model for a Faraday cage). Crank iterations up: more iterations means closer to the true solution. Drop it way down to see how Jacobi propagates information one cell per sweep (look at the early-iteration speckle pattern near isolated charges).

Parallel-Plate Capacitor with Bulk Charge

Here is a problem you can do entirely by hand, and which models a real device. Take two large flat conductors separated by a gap dd. Hold the left plate at V=0V = 0 and the right plate at V=V0V = V_0. Suppose the space between them is filled with a uniform space charge of density ρ\rho (think: electron cloud in a vacuum tube, or doped semiconductor depletion region). What does the potential look like in between?

Far from the edges of the plates, the field only depends on xx (the direction across the gap). Poisson reduces to a 1D ODE:

d2Vdx2=ρ/ε0\dfrac{d^2 V}{dx^2} = -\rho/\varepsilon_0

Integrate twice: V(x)=ρx2/(2ε0)+Ax+BV(x) = -\rho x^2/(2\varepsilon_0) + A x + B. The boundary conditions V(0)=0V(0) = 0 and V(d)=V0V(d) = V_0 fix B=0B = 0 and A=V0/d+ρd/(2ε0)A = V_0/d + \rho d/(2\varepsilon_0):

V(x)=ρ2ε0x2+ ⁣(V0d+ρd2ε0) ⁣xV(x) = -\frac{\rho}{2\varepsilon_0} x^2 + \!\left(\frac{V_0}{d} + \frac{\rho d}{2\varepsilon_0}\right)\! x

Two limiting cases make this physical:

CaseResult
No bulk charge (\rho = 0)V(x) = V_0\,x/d — linear voltage drop, uniform field E = -V_0/d.
Bulk charge \rho \ne 0Voltage profile bends into a parabola; field is no longer uniform — it is stronger at one plate than the other.
Numbers for the second case. Take V0=100VV_0 = 100\,\text{V}, d=1cmd = 1\,\text{cm}, ρ=106C/m3\rho = 10^{-6}\,\text{C/m}^3. Then at the midplane V(d/2)51.4VV(d/2) \approx 51.4\,\text{V} (not 50 V — the parabola bulges upward). The field at the negatively-biased plate is E(0)1.06×104V/mE(0) \approx -1.06\times 10^{4}\,\text{V/m} and at the positive plate E(d)9.44×103V/mE(d) \approx -9.44\times 10^{3}\,\text{V/m}. The asymmetry is the signature of bulk charge — and it's why semiconductor depletion-region calculations all start with Poisson.

Computing It: Finite-Difference Jacobi Solver

For anything more complicated than a sphere or parallel plates, you go numerical. The most transparent approach is finite differences + Jacobi relaxation:

  1. Replace the continuous Laplacian by its three-point (1D) or five-point (2D) stencil.
  2. Solve algebraically for the centre cell: Vi=12(Vi1+Vi+1+h2fi)V_i = \tfrac{1}{2}(V_{i-1} + V_{i+1} + h^2 f_i) in 1D, Vij=14(4 neighbours+h2fij)V_{ij} = \tfrac{1}{4}(\text{4 neighbours} + h^2 f_{ij}) in 2D.
  3. Sweep that update across the grid over and over until nothing changes. Because the discrete Laplacian is a strict contraction (after fixing the boundary), this loop converges from any starting guess.

Below is a complete, runnable Python implementation. It includes a manufactured-solution test: pick Vexact(x)=sin(πx)V_{\text{exact}}(x) = \sin(\pi x), compute Vexact=π2sin(πx)-V''_{\text{exact}} = \pi^2 \sin(\pi x), feed that into the solver, and verify it recovers sin(πx)\sin(\pi x) back. The max error of 3×104\sim 3 \times 10^{-4} is the discretization error from the finite stencil (it shrinks as h2h^2 as you refine the grid).

Finite-difference Poisson solver in plain Python
🐍poisson_1d.py
1📚 Import NumPy

NumPy gives us vectorized array operations (np.zeros, slicing like V[1:-1], np.max). Vectorized slicing replaces inner for-loops over grid points and runs orders of magnitude faster.

EXAMPLE
np.array([0,1,4,1,0])  →  shape (5,)
3Function signature

Defines solve_poisson_1d. It takes a known source term f = ρ/ε₀ on a grid, the grid spacing h, two boundary values, and stopping criteria. ⬇ inputs (example): rho_over_eps = [0, π², π², π², 0], h = 0.25, V_left = 0, V_right = 0. ⬆ returns: (V, iteration_count).

5Docstring + the equation being solved

We are discretizing -V''(x) = f(x). Finite differences turn the continuous second derivative into V_{i-1} - 2V_i + V_{i+1} over h². Rearranging gives the Jacobi update on line 23.

EXAMPLE
Continuous:  -V''(x) = f(x)
Discrete:    -(V_{i-1} - 2V_i + V_{i+1})/h² = f_i
14Read grid size from the source array

We size the solution V to match the source. Purpose: every interior grid point gets its own update; boundary points stay fixed.

EXAMPLE
rho_over_eps.shape = (50,)  →  N = 50
15Initialize V to zeros

np.zeros(N) makes a length-N array of zeros. Starting from zero is fine because the iteration converges to the true V regardless of initial guess (linear, contractive map).

EXAMPLE
V = [0., 0., 0., ..., 0.]   (length 50)
16Apply Dirichlet boundary at x = 0

Why: a unique solution to Poisson needs boundary data. V[0] is fixed for the entire iteration; the update on line 23 never touches it (slice V_new[1:-1] excludes the endpoints).

17Apply Dirichlet boundary at x = L

Same idea at the right endpoint. V[-1] in NumPy means the last element (index N-1).

EXAMPLE
V[-1] = 0.0  →  V = [0., 0., ..., 0.]
19Iteration loop

max_iter is a safety cap. We expect to break out early when the change between sweeps drops below tol. For 50 grid points with our test source this typically takes a few thousand sweeps — Jacobi is not fast, but it is dead-simple and parallel.

20Copy V into a fresh buffer

Jacobi must read the OLD V everywhere while writing the NEW V — never overwrite in place during a sweep. V.copy() makes an independent array. If you write in-place you get Gauss-Seidel (which actually converges faster but is harder to parallelize on a GPU).

EXAMPLE
V       = [0., 3., 5., 3., 0.]
V_new = V.copy()  →  separate array
22📚 The Jacobi update — heart of the solver

Discrete Poisson: -(V_{i-1} - 2V_i + V_{i+1})/h² = f_i. Solving for V_i gives V_i = ½(V_{i-1} + V_{i+1} + h² f_i). Each interior cell is replaced by the average of its two neighbours PLUS a source bump h²f_i. • V[:-2] ↔ V_{i-1} (left neighbour, slice indices 0..N-2) • V[2:] ↔ V_{i+1} (right neighbour, slice indices 2..N) • rho_over_eps[1:-1] ↔ f_i at interior cells only • V_new[1:-1]: write into interior only, leaving the boundary cells alone.

EXAMPLE
If V = [0,0,0,0,0] and h²f = [_,1,4,1,_] (interior),
after ONE sweep V_new = [0, 0.5, 2.0, 0.5, 0].
Converged answer: V = [0, 3, 5, 3, 0].
Check:  -(V[1] - 2V[2] + V[3])/1² = -(3 - 10 + 3) = 4  ✓ matches f[2].
24Convergence measure

We track the L-∞ norm of the change between iterations. When the largest single-cell update drops below tol, every cell is effectively in agreement with its discrete Poisson stencil, so we stop.

EXAMPLE
err history: 0.45, 0.31, 0.22, ..., 9.2e-10
25Swap buffers

V := V_new. Note this is a name rebinding (cheap) — V_new becomes the new V; the previous V is garbage-collected after.

26Early termination

Why bother iterating once the answer has converged? Bail out and return iteration count for diagnostics.

30Manufactured-solution test

Trick: pick a smooth V(x) you know the answer to, compute -V''(x), feed THAT into the solver, and check that you recover V back. Here V_exact(x) = sin(πx) gives -V'' = π² sin(πx). This is the gold-standard way to verify a PDE solver.

EXAMPLE
V_exact(0.5) = sin(π/2) = 1.0
31Grid size

N = 50 interior + boundary nodes spanning [0, 1]. Increasing N decreases h, which improves accuracy (truncation error is O(h²) for central differences) but increases iteration count.

32Grid spacing

h is the distance between adjacent grid points. With N=50 nodes on [0,1] there are N-1=49 intervals, so h = 1/49 ≈ 0.02041.

33📚 Build the grid

np.linspace(0, 1, 50) returns 50 evenly spaced points starting at 0 and ending at 1 inclusive. Purpose: x[i] tells us where each row of V lives in space.

EXAMPLE
np.linspace(0, 1, 5) → [0., 0.25, 0.5, 0.75, 1.0]
34📚 The source term

np.pi pulls in π = 3.14159…; np.sin is vectorized — calling np.sin(x) returns an array of sin values, one per grid point. We are choosing f(x) = π² sin(πx) precisely because we know -d²/dx² sin(πx) = π² sin(πx), so the exact answer is V = sin(πx).

EXAMPLE
f at x=0.5: π² × sin(π/2) = π² × 1 ≈ 9.8696
36Call the solver

Run Jacobi with our manufactured source. Default boundaries are 0, which matches sin(π·0) = sin(π·1) = 0.

EXAMPLE
returns V ≈ [0, 0.064, 0.127, ..., 0.064, 0]
37Build the analytic comparison

exact = sin(πx) is the true solution. Comparing V to exact tells us BOTH (a) the solver converged AND (b) the discretization is accurate.

EXAMPLE
exact[25] = sin(π·0.510) ≈ 0.9995
38L-∞ error

np.max(np.abs(...)) picks the worst single-point error. For 50 points this typically lands near 3.4e-04 — that is the discretization error from h², NOT the iteration error (which is below 1e-9).

EXAMPLE
max(|V − exact|) ≈ 3.42e-04
39Print diagnostics

Outputs: 'Converged in 7069 iterations, max error = 3.42e-04'. The high iteration count is Jacobi being slow — multigrid would solve this in ~30 iterations. But Jacobi is what you see here because it is the cleanest expression of 'curvature equals source'.

17 lines without explanation
1import numpy as np
2
3def solve_poisson_1d(rho_over_eps, h, V_left=0.0, V_right=0.0,
4                     max_iter=20000, tol=1e-9):
5    """Solve -V''(x) = rho(x)/eps0 on a 1D grid using Jacobi relaxation.
6
7    Args:
8        rho_over_eps: array of source values f_i = rho_i / eps0, shape (N,)
9        h:            grid spacing
10        V_left:       Dirichlet boundary V(0)
11        V_right:      Dirichlet boundary V(L)
12    Returns:
13        V: array of potential values, shape (N,)
14    """
15    N = rho_over_eps.shape[0]
16    V = np.zeros(N)
17    V[0]  = V_left
18    V[-1] = V_right
19
20    for iteration in range(max_iter):
21        V_new = V.copy()
22        # Jacobi update: V_i = 0.5 * (V_{i-1} + V_{i+1} + h^2 * f_i)
23        V_new[1:-1] = 0.5 * (V[:-2] + V[2:] + h * h * rho_over_eps[1:-1])
24        err = np.max(np.abs(V_new - V))
25        V = V_new
26        if err < tol:
27            break
28    return V, iteration + 1
29
30# --- Test: source with exact answer V(x) = sin(pi x) on [0, 1] ---
31N = 50
32h = 1.0 / (N - 1)
33x = np.linspace(0, 1, N)
34f = np.pi**2 * np.sin(np.pi * x)         # -V'' = pi^2 sin(pi x)
35
36V, n_iter = solve_poisson_1d(f, h)
37exact     = np.sin(np.pi * x)
38max_err   = np.max(np.abs(V - exact))
39print(f"Converged in {n_iter} iterations, max error = {max_err:.2e}")
Why Jacobi? It is the cleanest possible expression of "curvature equals source." Each grid cell literally averages its neighbours and adds a charge bump. The downside is it is slow: information about boundary conditions diffuses by one cell per sweep, so a grid of NN points takes O(N2)O(N^2) iterations to converge in 1D. Real-world solvers use multigrid (which fixes this to O(N)O(N)) or conjugate-gradient methods. But every one of those advanced methods is built on top of the relaxation idea you see here.

PyTorch on the GPU

The exact same Jacobi update vectorizes trivially to 2D, and PyTorch lets you run it on a GPU with one line change. Why bother? Two reasons. First, modern problems with millions of grid points are GPU-natural — the inner loop is just neighbour-averaging, which is exactly what convolutional kernels do. Second, the iteration is differentiable: PyTorch's autograd lets you backpropagate through it, which means you can use Poisson's equation as a layer inside a neural network (physics-informed networks, learned PDE solvers, neural-style Poisson image editing).

Vectorized 2D Poisson on the GPU
🐍poisson_2d_torch.py
1📚 Import PyTorch

torch gives us GPU-accelerated N-D tensors with the same slicing API as NumPy. The exact same Jacobi update we wrote in NumPy runs on a GPU just by moving the source tensor to device='cuda'.

EXAMPLE
x = torch.zeros(32, 32, device='cuda')
32D solver signature

Same idea as 1D but the grid is now an N×N tensor. The five-point stencil replaces the three-point stencil. ⬇ inputs: rho_over_eps shape (32,32), h = 1/31 ≈ 0.0323. ⬆ returns: V shape (32,32), iteration count.

11📚 Pick the device

.device on a tensor returns the device the data lives on (CPU or some CUDA GPU). Allocating V on the SAME device avoids a slow CPU↔GPU copy every iteration.

EXAMPLE
rho_over_eps.device  →  device(type='cuda', index=0)
12Read grid size

.shape[0] grabs the first dimension. We assume square grid for this demo.

EXAMPLE
rho_over_eps.shape = torch.Size([32, 32])  →  N = 32
13📚 Allocate V

torch.zeros(N, N, device=device) creates an N×N float32 tensor of zeros on the chosen device. Initial guess of zero is fine; Jacobi converges from any start because the discrete Laplacian is symmetric negative-definite.

EXAMPLE
V = tensor([[0,0,0,...], [0,0,0,...], ...], device='cuda:0')
15Outer Jacobi loop

Each iteration sweeps all (N-2)² interior cells in parallel on the GPU. No Python for-loop over space — only over iterations.

16📚 Snapshot V before update

.clone() creates a fresh tensor with its own memory (unlike .detach() which shares storage). We need the OLD values everywhere while we write NEW values — same Jacobi requirement as the NumPy version.

EXAMPLE
V_new shares no memory with V; modifying V_new[i,j] does not change V.
17📚 The 5-point Poisson stencil

Discrete 2D Laplacian: ∇²V_{i,j} ≈ (V_{i-1,j}+V_{i+1,j}+V_{i,j-1}+V_{i,j+1} − 4V_{i,j})/h². Setting that equal to -f and solving for V_{i,j} gives V_{i,j} = ¼(sum_of_4_neighbours + h²f_{i,j}). Slice cheat sheet for an N×N grid: • V[:-2, 1:-1] ↔ V_{i-1,j} (one row up) • V[2:, 1:-1] ↔ V_{i+1,j} (one row down) • V[1:-1, :-2] ↔ V_{i,j-1} (one column left) • V[1:-1, 2:] ↔ V_{i,j+1} (one column right) The assignment V_new[1:-1, 1:-1] = ... writes into the interior only — boundary stays at 0 (Dirichlet).

EXAMPLE
Centre cell (16,16) with neighbours all 0 and h²f = 0 → V_new[16,16] = 0.
Charge cell (10,12) with h²·500 source → V_new[10,12] grows over time toward ≈ 0.33.
23📚 Convergence check

(V_new - V).abs().max() computes the L-∞ change in this sweep on the GPU. • .abs() is element-wise absolute value • .max() reduces to a 0-D tensor (scalar) • .item() pulls the Python float out — this is the one synchronization point per iteration.

EXAMPLE
diff = 0.00000732  →  still iterating
24Rebind V

Replace V with V_new. Cheap pointer swap, no copy. The old V tensor gets freed at the end of this iteration.

25Stop when converged

Same early-exit as NumPy. Returns (V, iteration_count). For two point charges on a 32×32 grid this converges in ~1932 iterations.

30Set RNG seed

Pure determinism: torch.manual_seed(0) makes any random initialization reproducible across runs. We don't actually use randomness in this demo, but it's a good habit for any numerical experiment.

31Grid size

32×32 = 1024 interior points. Coarse enough to demo, fine enough to resolve a dipole pattern.

32Grid spacing

Same idea as 1D: 32 nodes on [0,1] gives 31 intervals, so h = 1/31 ≈ 0.0323. Smaller h ⇒ higher accuracy but more iterations to converge.

33📚 Allocate the source on the active device

device='cuda' if available else 'cpu' is the canonical PyTorch idiom for GPU-when-possible. Allocating ρ/ε₀ here means V (line 13) will also land on cuda — no implicit cross-device copies during the iteration.

EXAMPLE
rho_eps.device  →  device(type='cuda', index=0)   # if a GPU is present
34📚 Stamp a positive point charge

Discrete delta: dump 500 units of ρ/ε₀ into a single grid cell. In continuous terms this represents a charge density spike of magnitude 500 / h² (because the integral of a true delta is the total charge, and one cell has area h²).

EXAMPLE
rho_eps[10,12] = 500.0  →  cell (10,12) of the source becomes +500
35Stamp a negative charge

Same cell-stamp at (22, 20) with sign reversed. The pair forms a dipole — the classic asymmetric potential you see in the interactive panel above.

EXAMPLE
rho_eps[22,20] = -500.0
37Run the solver

Calls solve_poisson_2d_gpu. The entire inner loop is vectorized over (N-2)² cells: on a GPU that is ~1000 cells updated in parallel per iteration.

EXAMPLE
(V, n_iter) ≈ (32×32 tensor, 1932)
38Inspect the answer

Output (verified by running): iters=1932, V[10,12]= 0.3277, V[22,20]=-0.3215, V_center=2.72e-03. The near-zero centre value is the dipole signature: contributions from + and − roughly cancel at the symmetry plane.

19 lines without explanation
1import torch
2
3def solve_poisson_2d_gpu(rho_over_eps, h, max_iter=5000, tol=1e-8):
4    """Solve nabla^2 V = -rho/eps0 on a 2D grid using vectorized Jacobi on GPU.
5
6    Args:
7        rho_over_eps: (N, N) tensor of source values
8        h:            grid spacing
9    Returns:
10        V: (N, N) tensor with Dirichlet V=0 on the boundary
11    """
12    device = rho_over_eps.device
13    N      = rho_over_eps.shape[0]
14    V      = torch.zeros(N, N, device=device)
15
16    for it in range(max_iter):
17        V_new           = V.clone()
18        V_new[1:-1, 1:-1] = 0.25 * (
19            V[:-2, 1:-1] + V[2:, 1:-1]      # vertical neighbours
20          + V[1:-1, :-2] + V[1:-1, 2:]      # horizontal neighbours
21          + h * h * rho_over_eps[1:-1, 1:-1]
22        )
23        diff = (V_new - V).abs().max().item()
24        V    = V_new
25        if diff < tol:
26            break
27    return V, it + 1
28
29# --- Two opposite point charges ---
30torch.manual_seed(0)
31N        = 32
32h        = 1.0 / (N - 1)
33rho_eps  = torch.zeros(N, N, device="cuda" if torch.cuda.is_available() else "cpu")
34rho_eps[10, 12] = +500.0
35rho_eps[22, 20] = -500.0
36
37V, n_iter = solve_poisson_2d_gpu(rho_eps, h)
38print(f"iters={n_iter}, V[10,12]={V[10,12]:.4f}, V[22,20]={V[22,20]:.4f}, V_center={V[16,16]:.4e}")

Running this on the two-charge dipole above gives V[10,12]+0.328V[10,12] \approx +0.328, V[22,20]0.322V[22,20] \approx -0.322, and the potential at the geometric centre is nearly zero (2.7×103\approx 2.7\times 10^{-3}). The small asymmetry between the two extrema comes from the fact that the two charges aren't equally far from the boundary — the Dirichlet edge pulls the potential slightly more strongly toward zero on whichever side is closer.


Method of Images: A Trick from Symmetry

Before computers, a generation of physicists used a beautiful trick to solve otherwise-hard Poisson problems: the method of images. Suppose you have a single positive charge +q+q sitting at height hh above an infinite grounded conducting plane (V=0V = 0 on the plane). The potential in the upper half-space is hard — there are induced surface charges on the plane that you don't know in advance.

The trick: replace the conductor by an imaginary charge q-q at depth h-h. The plane becomes the perpendicular bisector of the line joining +q+q and q-q, and by symmetry the potential along that bisector is exactly zero — exactly the boundary condition we need! Now there's no conductor anywhere: just two free-space charges, and the potential in the upper half-plane is simply the sum of their Coulomb potentials.

V(r)=14πε0 ⁣(qrr+qrr)V(\mathbf{r}) = \frac{1}{4\pi\varepsilon_0}\!\left(\frac{q}{|\mathbf{r} - \mathbf{r}_+|} - \frac{q}{|\mathbf{r} - \mathbf{r}_-|}\right)

Why this works mathematically: Poisson's equation has unique solutions given the sources and the boundary conditions. The image construction reproduces both the correct sources (only the real charge is in the physical region) and the correct boundary (V=0V = 0 on the plane). Uniqueness then guarantees it's the right answer.

Try this in the interactive widget above. Place a positive charge somewhere in the upper half of the grid and a negative charge of the same magnitude directly below it, mirrored across the horizontal midline. Crank up the iterations. You'll see the equipotential line along the midline is essentially zero — the widget reproduces the method-of-images solution from first principles.

Connections to Machine Learning

Poisson's equation isn't just a physics curiosity — it appears in modern machine learning in surprisingly direct ways.

  • Poisson image editing. When you paste a region from one photo into another, the seam usually shows. The Perez/Gangnet 2003 algorithm solves a Poisson equation where the source ff is the gradient of the source patch and the Dirichlet boundary is the destination image at the seam. The result blends seamlessly — and the solver is exactly the 2D Jacobi (or multigrid) you saw above. Modern photo editors (including the "Healing Brush") use this.
  • Physics-informed neural networks (PINNs). A PINN trains a neural network Vθ(x,y)V_\theta(x,y) by minimizing the residual 2Vθ+ρ/ε02\|\nabla^2 V_\theta + \rho/\varepsilon_0\|^2 at sampled points, plus a boundary loss. The Laplacian inside the loss is computed by autograd — exactly the same tool that backpropagates through every other neural net. The PyTorch code above is the "classical" teacher; a PINN is the neural student.
  • Graph Laplacian regularization. In semi-supervised learning, you spread labels across a graph by solving Lf=0L f = 0 where LL is the graph Laplacian. This is exactly Laplace's equation on a discrete graph — Poisson's equation with zero source. The labelled nodes act as Dirichlet boundary conditions, and the propagation rule is the discrete "average of neighbours" you saw at the start of this section.
  • Diffusion models. Score-based generative models can be reframed as solving a partial differential equation related to the heat (Fokker-Planck) equation, and the steady-state of that equation is Poisson's equation. The same mathematics that places voltage around charges places probability mass around training data points.

Summary

IdeaWhat you should remember
Poisson's equation\nabla^2 V = -\rho/\varepsilon_0 — Laplacian of potential equals minus charge density over permittivity.
IntuitionPositive charge sits above the average of its neighbours; negative charge sits below; no charge means the potential is the local average (mean value property).
DerivationGauss's law plus E = -\nabla V collapses to Poisson in one substitution.
Boundary conditionsDirichlet (fixed voltage on conductors) is the most common; Neumann fixes surface charge; both make the solution unique.
Canonical worked exampleUniformly charged sphere: parabolic V inside, 1/r outside, glued at r = R. Outside is indistinguishable from a point charge.
Numerical workhorseFinite difference + Jacobi relaxation: each cell averages its neighbours plus a charge bump. Slow but bulletproof; foundation for multigrid.
ML connectionPoisson image blending, PINNs, graph Laplacian semi-supervision, diffusion-model steady states.
The takeaway in one sentence. Once you can read 2V=ρ/ε0\nabla^2 V = -\rho/\varepsilon_0 as "the potential at a point exceeds the average of its neighbours in proportion to the local charge," every numerical method, every analytic trick, and every machine-learning application of this equation becomes a variation on a single, intuitive theme.
Loading comments...