This tutorial builds a real device: a 2.4 GHz rectangular patch on a finite FR4 board. It teaches four things you will need for your own models:
- sizing a starting design from closed-form formulas;
- a graded z mesh that resolves a thin substrate without refining the whole domain;
- declaring copper as sheets;
- reading preflight output, and knowing what a short run can and cannot tell you.
It ends with a quick execution check. Reporting a resonance, a return loss or a Q needs more than that, and the last section lists what.
1. Starting dimensions
The usual transmission-line model gives a first guess for the patch length L and width W:
import numpy as np
C0 = 299_792_458.0f0 = 2.4e9 # design frequencyeps_r = 4.4 # nominal FR4h = 1.5e-3 # substrate thicknesstan_d = 0.02 # nominal FR4 loss tangent
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.5dL = 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
# A constant conductivity that matches tan(delta) at f0 only.sigma_fr4 = 2 * np.pi * f0 * 8.854_187_8128e-12 * eps_r * tan_dprint(f"L = {L * 1e3:.2f} mm, W = {W * 1e3:.2f} mm")These formulas give a starting point, not a reference to validate the solver against. Real FR4 permittivity and loss depend on the laminate and the frequency, so use your material’s data.
2. A graded z mesh
The substrate is 1.5 mm thick. Resolving it with 6 cells needs 0.25 mm cells,
but the air around the antenna is fine at 1 mm. A dz_profile lists the z cell
sizes from bottom to top, so you can use fine cells only where you need them:
from rfx import Box, GaussianPulse, Simulationfrom rfx.mesh_edges import edge_aware_profiles
dx = 1.0e-3 # nominal x and y cell sizedz_sub = h / 6 # 6 cells through the substrate
# Grade 1.0 mm -> 0.25 mm with each neighbour ratio below 1.3.transition = np.array([1.000, 0.780, 0.610, 0.477, 0.373, 0.291, 0.250]) * 1e-3lower_air = np.concatenate([np.full(10, dx), transition[1:]])upper_air = np.concatenate([transition[-2::-1], np.full(24, dx)])dz_profile = np.concatenate([lower_air, np.full(6, dz_sub), upper_air])
z_sub_lo = float(lower_air.sum()) # substrate bottomz_sub_hi = z_sub_lo + h # substrate top
dom_x, dom_y = 0.100, 0.095gx_lo, gx_hi = 0.020, 0.080gy_lo, gy_hi = 0.020, 0.075px0, px1 = dom_x / 2 - L / 2, dom_x / 2 + L / 2py0, py1 = dom_y / 2 - W / 2, dom_y / 2 + W / 2ground = Box((gx_lo, gy_lo, z_sub_lo), (gx_hi, gy_hi, z_sub_lo))substrate = Box((gx_lo, gy_lo, z_sub_lo), (gx_hi, gy_hi, z_sub_hi))patch = Box((px0, py0, z_sub_hi), (px1, py1, z_sub_hi))feed_x, feed_y = px0 + 8e-3, dom_y / 2probe_x, probe_y = dom_x / 2 + 5e-3, dom_y / 2 + 5e-3profiles = edge_aware_profiles( (dom_x, dom_y, float(dz_profile.sum())), dx, sheets=[ground, patch], solids=[substrate], faces={"x": [feed_x, probe_x], "y": [feed_y, probe_y]}, axes="xy",)sim = Simulation( freq_max=4e9, domain=(dom_x, dom_y, 0.0), # z size comes from sum(dz_profile) dx=dx, dz_profile=dz_profile, **profiles, boundary="cpml", cpml_layers=8,)Three rules for graded meshes:
- Keep neighbouring cell sizes within a ratio of about 1.3.
- Start and end the profile with cells equal to
dx, so the absorber sees the same cell size on every face. - Put material interfaces exactly on cell boundaries. Here the substrate
starts at
sum(lower_air)for that reason.
Non-Uniform Mesh covers graded meshes in depth.
3. Board, copper, source and probe
The ground plane and the patch are copper foil, which rfx models as sheets of zero thickness on one grid plane (see Core Concepts):
sim.add_material("fr4_design", eps_r=eps_r, sigma=sigma_fr4)
sim.add(substrate, material="fr4_design")sim.add_thin_conductor(ground)sim.add_thin_conductor(patch)
# A soft source under the patch, 8 mm in from the edge, and an offset probe.src_z = z_sub_lo + 2.5 * dz_subsim.add_source((feed_x, feed_y, src_z), "ez", waveform=GaussianPulse(f0=f0, bandwidth=0.8), amplitude_kind="current")sim.add_probe((probe_x, probe_y, src_z), "ez")
report = sim.preflight()print(report.format())Reading what rfx tells you
Each add_thin_conductor call warns that the sheet is a lossless PEC
sheet, because copper’s conductivity is above the 1e6 S/m PEC threshold.
The patch therefore has no conductor loss. If conductor loss matters for your
Q or efficiency, pass surface_impedance_f0=f0. The sheet is then modelled as
a resistive sheet with copper’s surface resistance at f0, on the same
footprint, instead of as PEC.
The in-plane edge_aware_profiles mesh registers the ground and patch to
their solved edges and the feed and probe to grid nodes. This keeps the drawn
patch dimensions without rounding them to a uniform lattice. Read
sim.fidelity_report() and the preflight findings to check that realization;
registration does not replace a mesh-convergence study.
This edge-aware in-plane grading raises four nu_grading_reaches_absorber
advisories because the interior cells adjacent to the x/y absorber faces are
not uniform. The grading changes the discrete medium normal to the boundary
where the absorber starts, leaving no interior runway to separate the
transition’s reflection from the absorber’s, so this example is outside the
validated multi-band accuracy envelope.
Treat preflight warnings as part of your result. Use preflight(strict=True)
only for models you expect to have no findings at all, because strict mode turns
every finding into an error.
4. A short execution check
short_run = sim.run(num_periods=2)ts = np.asarray(short_run.time_series)assert ts.size > 0 and np.isfinite(ts).all()print(f"{ts.shape[0]} steps, settling {short_run.settling_db:.1f} dB")This shows that the model runs and produces finite data. Two periods is far too short for the patch to ring down, so rfx warns that the ring-down is truncated. Nothing from this run is a result.
5. From a model to a result
To report a resonance, Q or return loss for this patch:
- Run long enough. Use
until_decay=or a long fixed record, and checkresult.settling_db. A patch has a moderately high Q, so it needs many periods. - Extract the resonance with
result.find_resonances(freq_range=(2e9, 3e9)). Pick the mode by its field pattern or far field, not by which peak is biggest. Handle the case where no mode is found. - Refine the mesh in x/y and z, and enlarge the air margin. Report how much the frequency moves, not just the finest answer. See Convergence Study.
- For return loss, use a port. The soft source here has no impedance, so it cannot give S11. Use a lumped or microstrip port and its calculator from Sources and Ports.
- Compare with a reference. Use another solver or a measurement. Benchmarks shows how rfx’s own patch case compares with openEMS.
examples/tutorials/patch_antenna_demo.py carries out this workflow end to end,
including far-field mode identification. Examples lists the
full learning path.
