Your First Patch Antenna
This tutorial designs a 2.4 GHz rectangular microstrip patch on FR4, runs the FDTD simulation, and extracts the resonant frequency with Harminv. Estimated time: 20 minutes.
If you want an external-solver check of the same class of structure after this resonance workflow is stable, continue to Tutorial: Patch Antenna Design.
1. Analytical patch design
Section titled “1. Analytical patch design”The standard rectangular patch formulas (Bahl & Trivedi 1977) give the physical dimensions:
W = c / (2·f₀) · √(2 / (εᵣ+1))εₑff = (εᵣ+1)/2 + (εᵣ−1)/2 · (1 + 12h/W)^−½ΔL = 0.412·h · [(εₑff+0.3)(W/h+0.264)] / [(εₑff−0.258)(W/h+0.8)]L = c / (2·f₀·√εₑff) − 2·ΔLIn Python:
import numpy as npC0 = 299_792_458.0 # m/s
f0 = 2.4e9 # target resonanceeps_r = 4.4 # FR4h = 1.6e-3 # substrate thickness (1.6 mm)tan_d = 0.02 # loss tangent at 2.4 GHz
W = C0 / (2*f0) * np.sqrt(2 / (eps_r + 1))eps_eff = (eps_r+1)/2 + (eps_r-1)/2 * (1 + 12*h/W)**(-0.5)dL = 0.412*h * ((eps_eff+0.3)*(W/h+0.264) / ((eps_eff-0.258)*(W/h+0.8)))L = C0 / (2*f0*np.sqrt(eps_eff)) - 2*dL
# FR4 conductivity from loss tangentsigma_fr4 = 2*np.pi*f0 * 8.854e-12 * eps_r * tan_d
print(f"L = {L*1e3:.2f} mm, W = {W*1e3:.2f} mm")print(f"eps_eff = {eps_eff:.3f}, dL = {dL*1e3:.3f} mm")# Typical output: L = 29.38 mm, W = 38.01 mm2. Set up the Simulation
Section titled “2. Set up the Simulation”Use a quarter-wavelength margin around the patch and a non-uniform z-profile to resolve the thin substrate without inflating the cell count.
from rfx import Simulation, Box, GaussianPulseimport numpy as np
# Domain: patch + λ/4 margin on all sideslam0 = C0 / f0 # free-space wavelength ~125 mmmargin = lam0 / 4 # 31 mm absorber margin
dom_x = L + 2*margindom_y = W + 2*margindom_z = h + margin # substrate + air above
# Non-uniform z profile: fine cells inside substrate, coarse in airdz_sub = h / 6 # 6 cells through 1.6 mm substratedz_air = 1.5e-3 # 1.5 mm cells in airn_air = max(1, int(round(margin / dz_air)))
dz_profile = np.concatenate([ np.full(6, dz_sub), np.full(n_air, dz_air),])
sim = Simulation( freq_max=4e9, domain=(dom_x, dom_y, dom_z), boundary="cpml", cpml_layers=12, dx=5e-4, # 0.5 mm lateral cell dz_profile=dz_profile,)!!! tip
auto_configure() can derive dz_profile automatically. Use it when you prefer
a one-shot configuration:
from rfx import auto_configuregeom = [(Box((0,0,0),(dom_x,dom_y,h)), "fr4")]cfg = auto_configure(geom, freq_range=(1e9, 4e9), accuracy="standard")sim = Simulation(**cfg.to_sim_kwargs())3. Define materials and geometry
Section titled “3. Define materials and geometry”# Register FR4 with frequency-accurate loss tangent at 2.4 GHzsim.add_material("fr4_24ghz", eps_r=4.4, sigma=sigma_fr4)
# Ground plane (z = 0, one cell thick)sim.add(Box((0, 0, 0), (dom_x, dom_y, dz_sub)), material="pec")
# Substrate slabsim.add(Box((0, 0, 0), (dom_x, dom_y, h)), material="fr4_24ghz")
# Patch (sitting on top of substrate; one cell thick in z)px0, py0 = margin, marginsim.add(Box((px0, py0, h), (px0+L, py0+W, h+dz_sub)), material="pec")!!! note
rfx rasterises geometry onto the Yee grid. A patch of physical thickness dz_sub
is only one cell thick in z, which is standard practice for printed conductors.
4. Add source and probe
Section titled “4. Add source and probe”The probe-feed is placed at one-third of the patch length from the radiating edge:
feed_x = px0 + L/3feed_y = py0 + W/2feed_z = h / 2 # midpoint of substrate
sim.add_source( (feed_x, feed_y, feed_z), component="ez", waveform=GaussianPulse(f0=f0, bandwidth=0.8),)sim.add_probe((feed_x, feed_y, feed_z), "ez")add_source injects a soft (no impedance loading) current source — ideal for resonance characterisation. Use add_port if you want S-parameter extraction against a 50 Ω reference.
5. Run the simulation
Section titled “5. Run the simulation”# Build grid to get dt, then set n_steps for ~10 ns simulation timegrid = sim._build_grid()n_steps = int(np.ceil(10e-9 / grid.dt))print(f"Grid: {grid.shape}, dt={grid.dt*1e12:.2f} ps, n_steps={n_steps}")
result = sim.run(n_steps=n_steps)6. Extract resonance with Harminv
Section titled “6. Extract resonance with Harminv”modes = result.find_resonances(freq_range=(1.5e9, 3.5e9))
if modes: best = min(modes, key=lambda m: abs(m.freq - f0)) err = abs(best.freq - f0) / f0 * 100 print(f"FDTD resonance : {best.freq/1e9:.4f} GHz") print(f"Analytical : {f0/1e9:.4f} GHz") print(f"Error : {err:.2f} %") print(f"Q : {best.Q:.0f}")!!! note The ~3 % difference between FDTD and the analytical formula is expected and is a known limitation of the Hammerstad-Jensen fringe-correction formula, not an rfx accuracy problem. See Cross-Validation.
7. Optional: exploratory lumped-port run
Section titled “7. Optional: exploratory lumped-port run”If you want a quick diagnostic S-parameter run, you can re-run with a lumped port instead of a soft source:
sim2 = Simulation( freq_max=4e9, domain=(dom_x, dom_y, dom_z), boundary="cpml", cpml_layers=12, dx=5e-4, dz_profile=dz_profile,)sim2.add_material("fr4_24ghz", eps_r=4.4, sigma=sigma_fr4)sim2.add(Box((0, 0, 0), (dom_x, dom_y, dz_sub)), material="pec")sim2.add(Box((0, 0, 0), (dom_x, dom_y, h)), material="fr4_24ghz")sim2.add(Box((px0, py0, h), (px0+L, py0+W, h+dz_sub)), material="pec")sim2.add_port( (feed_x, feed_y, feed_z), "ez", impedance=50, waveform=GaussianPulse(f0=f0, bandwidth=0.8),)
result2 = sim2.run(n_steps=n_steps, compute_s_params=True)
import matplotlib.pyplot as pltfreqs = result2.freqss11_db = 20 * np.log10(np.abs(result2.s_params[0, 0, :]) + 1e-12)
plt.figure()plt.plot(freqs / 1e9, s11_db)plt.xlabel("Frequency (GHz)")plt.ylabel("|S11| (dB)")plt.title("Patch antenna S11 — FR4 2.4 GHz")plt.ylim(-30, 2)plt.grid(True)plt.tight_layout()plt.savefig("patch_s11.png", dpi=150)plt.show()!!! warning For patch and microstrip feeds, treat this lumped-port S11 result as a diagnostic workflow, not yet as the canonical public validation path. The current recommended path for this tutorial is the current-source + Harminv resonance workflow above.
Expected results
Section titled “Expected results”| Quantity | Value |
|---|---|
| FDTD resonance | ~2.33–2.38 GHz |
| Analytical prediction | 2.40 GHz |
| Discrepancy | ~1–3 % (formula limitation) |
| Q factor | 20–60 (lossy FR4) |
| Diagnostic lumped-port S11 | depends strongly on feed position / port model |
Full script
Section titled “Full script”For the current public patch cross-check, prefer
examples/crossval/05_patch_antenna.py.
That crossval is the newest committed public patch workflow in this repo and is the script we currently use for the OpenEMS-backed external sanity check described in Tutorial: Patch Antenna Design.
Older patch example scripts can still be useful as local experiments, but they should not be treated as the primary public reference path unless they are checked against the current public workflow.