Probe-fed patch: mesh convergence and openEMS cross-check
Measured 2026-10-15 on macOS ARM64 (14 cores, 36 GB), Release build with
UMFPACK (EDGEFEM_USE_SUITESPARSE=ON). EdgeFEM at commit 13cab87 (surface
mass ABC) with the Python defaults of 23c6271; the h/4 row and the
known-answer rerun after UMFPACK moved to 64-bit indices (see below).
openEMS from the antenna-cad-openems Docker image (openEMS-Project
84703e2).
Geometry
The 2.45 GHz FR-4 patch of the known-answer test
(python/tests/test_patch_physics.py), built by PatchAntennaDesign:
| Item | Value |
|---|---|
| Patch | L 28.6 mm (y), W 37.3 mm (x), PEC sheet at z = h |
| Substrate | FR-4, h 1.6 mm, eps_r 4.4, tan d 0.02, 111.9 x 111.9 mm |
| Ground | PEC at z = 0, 111.9 x 111.9 mm (3 x W), same size as the substrate |
| Probe | radius 0.5 mm, at y = -9 mm from the patch center: square PEC post of half-side 0.424 mm (equivalent radius 0.59 x side) |
| Ground hole | square, half-side 0.975 mm (hole/probe radius ratio 2.30) |
| Port | 50 ohm gap port on the strip from the post face to the hole edge at z = 0, E along +x |
| Cavity model (Balanis 14.2, Hammerstad dL) | 2.4669 GHz |
EdgeFEM convergence
Reproduce (export PYTHONPATH=build-fix/python:python):
- rows density 8, 12, 16:
python tools/patch_convergence.py --case fr4 --density 8 12 16 --patch-factor 6 - rows density 12 at h/3 and h/4:
python tools/patch_convergence.py --case fr4 --density 12 --patch-factor 4 3
The 8/12/16 rows use the near-patch factor 6.0 of the study, not the
current default 4.0; without --patch-factor 6 the tool uses 4.0.
Air box lambda/2 at 1.8 GHz. f_res is the |S11| minimum of a 2.25 to
2.60 GHz sweep in 10 MHz steps, refined by a parabola through the three
points around it; Re(Z) off resonance comes from a 1.8 to 3.0 GHz sweep
in 100 MHz steps. The near-patch size is
_PATCH_SIZE_X_DENSITY * h / density. Time is the two sweeps (49 LU
factorizations) plus meshing; memory is the peak resident set of the
process.
| Density | Near-patch size | Edges | f_res (GHz) | vs cavity | min |S11| (dB) | Z at f_res (ohm) | Re Z 1.8 / 3.0 GHz (ohm) | Time | Peak |
|---|---|---|---|---|---|---|---|---|---|
| 8 | 0.75 h | 63,950 | 2.3125 | -6.26% | -15.24 | 63.8 - j14.5 | 0.99 / 1.66 | 32 s | 7.5 GB |
| 12 | h/2 | 121,532 | 2.3600 | -4.34% | -14.53 | 60.3 - j18.3 | 0.50 / 1.40 | 97 s | 7.2 GB |
| 16 | 0.375 h | 225,156 | 2.3703 | -3.92% | -14.39 | 60.8 - j18.5 | 0.47 / 1.35 | 295 s | 8.9 GB |
| 12 | h/3 (default) | 258,355 | 2.3768 | -3.65% | -14.39 | 55.7 - j19.9 | 0.45 / 1.34 | 303 s | 9.7 GB |
| 12 | h/4 | 456,162 | 2.3880 | -3.20% | -14.37 | 57.2 - j19.7 | 0.42 / 1.31 | 785 s | 11.2 GB |
Peak memory of the first four rows was measured with the 32-bit-index
UMFPACK interface. The h/4 row needed the 64-bit one: with 32-bit indices
every factorization failed in about 10 s with status -1
(UMFPACK_ERROR_out_of_memory) and symbolic estimates of 76.4 GB for the
numeric object and 77.1 GB peak, more than 32-bit indices can address. The
64-bit interface factors it with 6.4 GB peak (one frequency, 20 s); the
estimate is an upper bound. The 64-bit interface also raises the peak of
the other runs: 7.5 -> 8.9 GB at density 8, 8.4 -> 10.6 GB for the
known-answer test, with the same S11 to the printed digits.
f_res rises as the mesh near the patch shrinks: +47.5 MHz from 0.75 h to
h/2, +16.8 MHz from h/2 to h/3, +11.2 MHz from h/3 to h/4. At h/4
EdgeFEM is 0.04% below openEMS (2.389 GHz, next section). The default
near-patch size is h/3 at density 12 (_PATCH_SIZE_X_DENSITY = 4.0),
0.47% below the h/4 value at 39% of its time; h/2 is 1.2% below at 12%.
The known-answer test (25 MHz steps, air box lambda/2 at 2.25 GHz) finds
2.375 GHz, -3.73% from the cavity model, min |S11| -14.39 dB, in 106 s and
10.6 GB (pytest python/tests -m slow, 64-bit UMFPACK). It found
2.350 GHz (-4.74%) with the h/2 default.
ABC: surface mass matrix
Commit 13cab87 replaced j k0 on the diagonal of each ABC edge with
j k0 times the assembled surface mass matrix. Density 12, near-patch
h/2, same sweeps as above. Reproduce: the lambda/2 rows are the density-12
row of the convergence commands; the lambda/4 rows are
python tools/patch_convergence.py --case fr4 --density 12 --patch-factor 6 --air-box-height 0.0306;
the "diagonal" rows need a build of 697ec6a on PYTHONPATH.
| Air box | ABC | f_res (GHz) | min |S11| (dB) | Re Z 1.8 GHz | Re Z 3.0 GHz |
|---|---|---|---|---|---|
| lambda/2 at 1.8 GHz (83 mm) | diagonal | 2.3586 | -15.11 | 0.49 | 1.38 |
| lambda/2 at 1.8 GHz (83 mm) | surface mass | 2.3600 | -14.53 | 0.50 | 1.40 |
| 30.6 mm (lambda/4 at 2.45 GHz) | diagonal | 2.3652 | -12.70 | 0.49 | 1.27 |
| 30.6 mm (lambda/4 at 2.45 GHz) | surface mass | 2.3621 | -14.55 | 0.52 | 1.36 |
CONFIRMED (table): with the surface mass ABC, the lambda/4 and lambda/2 boxes agree to 2.1 MHz in f_res and 0.02 dB in min |S11|; with the diagonal ABC they differed by 6.6 MHz and 2.4 dB. The off-resonance Re(Z) of 16.5 ohm (1.8 GHz) and 26.4 ohm (3.0 GHz) in the 2026-10-12 investigation came from the model before the gap-port and geometry fixes; on the current model it is 0.5 and 1.4 ohm with either ABC, and openEMS gives 0.38 to 0.53 and 1.08 to 1.20 ohm (gap-port runs below).
openEMS cross-check
Reproduce, one command per row (runs openEMS in Docker; see the script
header): python3 tools/openems_patch_crosscheck.py --case fr4 --workdir <dir>
plus nothing, --edge-res 0.2 --n-sub 8, --ground infinite and
--feed vertical for the four rows. Same patch, substrate,
ground, post, hole and gap port as EdgeFEM; 1.8 to 3.0 GHz Gaussian
excitation, 1 MHz output steps, PML_8 on all six sides, lambda/4 of air
around the board, end criterion -50 dB (reached in every run), thirds rule
at the patch edges.
| Run | Patch-edge step | Cells across h | Cells | f_res (GHz) | min |S11| (dB) | Z at f_res (ohm) | f at max Re Z (GHz) | Time |
|---|---|---|---|---|---|---|---|---|
| finite ground | 0.4 mm | 4 | 435,891 | 2.379 | -12.95 | 68.1 - j20.0 | 2.356 | 285 s |
| finite ground | 0.2 mm | 8 | 576,300 | 2.389 | -13.03 | 66.6 - j20.6 | 2.364 | 623 s |
| ground and substrate through the PML | 0.4 mm | 4 | 427,893 | 2.379 | -13.17 | 67.8 - j19.2 | 2.356 | 246 s |
| vertical lumped port, no post or hole | 0.4 mm | 4 | 435,891 | 2.392 | -16.89 | 56.6 - j13.9 | 2.362 | 261 s |
EdgeFEM at the default mesh (density 12, h/3) against openEMS on the finer mesh:
| EdgeFEM | openEMS | Difference | |
|---|---|---|---|
| f_res | 2.3768 GHz | 2.389 GHz | -0.51% |
| min |S11| | -14.39 dB | -13.03 dB | 1.4 dB deeper |
| Z at f_res | 55.7 - j19.9 ohm | 66.6 - j20.6 ohm | |
| f at max Re Z | 2.35 GHz (10 MHz grid) | 2.364 GHz | -0.6% |
CONFIRMED (tables): f_res agrees to 0.51%, inside the 2% target. At density 12 with h/2 near the patch EdgeFEM is 1.2% low (2.3600 GHz); with h/4 it is 0.04% low (2.3880 GHz). Both solvers put the resonance 3.2% (openEMS) to 3.65% (EdgeFEM) below the cavity model; the vertical-port feed moves openEMS by +0.55% in f_res and +0.25% in the Re Z peak, so the gap from the cavity model is not a feed artifact (SUSPECTED cause: the Hammerstad length extension and eps_reff for this W/h).
Where the two models differ:
- Domain truncation. EdgeFEM's domain is the ground-plane footprint: the ABC side walls stand on the ground edges and there is nothing below the ground. openEMS has a finite ground in air with lambda/4 of air on all sides. Extending the openEMS ground and substrate through the PML changed f_res by 0 MHz and min |S11| by 0.2 dB, so the ground edge does not drive the difference here.
- The ground hole outside the port strip is a natural (PMC) boundary in EdgeFEM and open to the air below in openEMS.
- Loss: EdgeFEM uses eps_r (1 - j tan d) at every frequency; openEMS a conductivity kappa = 2 pi f0 eps0 eps_r tan d with f0 = 2.45 GHz, so tan d is 0.02 only at 2.45 GHz (0.0206 at 2.38 GHz).
- Discretization: tetrahedral first-order edge elements versus a staircase-free Cartesian FDTD grid (every face is axis-aligned).
EdgeArray's 10 GHz RO4350B patch
python tools/patch_convergence.py --case ro4350b_10g --density 6 10 12
(default near-patch size) and, for the openEMS numbers,
python3 tools/openems_patch_crosscheck.py --case ro4350b_10g --workdir <dir>
with nothing, --probe-radius 0.25 and --feed vertical. L 7.679 mm, W 9.820 mm,
h 0.508 mm, eps_r 3.66, tan d 0.0037, ground 24.81 mm, probe radius 0.5 mm
at the inset point (y = -L/2 + 2.131 mm), as EdgeArray's EdgeFEM worker
builds it; f_min 8.5 GHz. Cavity model 10.000 GHz. The sweep is
EdgeArray's live-test sweep, 8.5 to 11.5 GHz in 100 MHz steps.
| Density | Near-patch | Edges | min |S11| (dB) at | Interior argmin | Phase spread | f_res vs 10 GHz | Time |
|---|---|---|---|---|---|---|---|
| 6 | 0.67 h | 45,124 | -13.77 at 9.0 GHz | yes | 364 deg | -10.0% | 34 s |
| 10 | 0.4 h | 97,114 | -14.09 at 9.3 GHz | yes | 367 deg | -7.0% | 121 s |
| 12 | h/3 | 145,912 | -19.31 at 9.3 GHz | yes | 368 deg | -7.0% | 278 s |
With 25 MHz steps, f_res is 9.046, 9.254 and 9.296 GHz at density 6, 10 and 12. openEMS on the same model gives 9.510 GHz (-17.35 dB); with a 0.25 mm probe 9.593 GHz, with a vertical lumped port 9.620 GHz. So the probe-fed model of this patch resonates 4 to 5% below the cavity model in openEMS, and EdgeFEM at density 12 is another 2.3% below openEMS. The case meets min |S11| < -6 dB, interior argmin and phase spread > 30 deg; it fails f_res within 5% of 10 GHz at every density, and would still fail if EdgeFEM matched openEMS exactly (9.51 GHz is -4.9%, on the edge).