Skip to content

Non-Uniform Mesh

rfx provides a graded z-profile (non-uniform Yee grid) with uniform dx and dy. In the current support contract, this is a limited-support path: retained for thin-substrate workflows, but not the default validated reference lane.

  • retained for thin-substrate / layered RF qualification work
  • no silent fallback / no silent feature dropping
  • unsupported combinations now fail clearly

Current unsupported combinations:

  • periodic-port workflows + non-uniform z mesh
  • NTFF + non-uniform z mesh
  • DFT planes + non-uniform z mesh
  • TFSF + non-uniform z mesh
  • waveguide ports + non-uniform z mesh except the documented restricted compute_waveguide_s_matrix(normalize=True) single-mode limited-support path
  • single-cell lumped-port S-parameters + non-uniform z mesh
  • compute_msl_s_matrix() + non-uniform z mesh
  • coaxial ports + non-uniform z mesh
  • lumped RLC + non-uniform z mesh

SituationRecommendation
Substrate much thinner than wavelength (e.g., 1.6 mm FR4 at 2.4 GHz, lambda = 125 mm)Non-uniform z is the limited-support tool to evaluate
Layer stack with multiple thin dielectric sheetsNon-uniform z for each layer, with limited-support caveats
Bulk 3-D structure with no thin z-featuresUniform grid
RCS / far-field onlyUniform grid is usually sufficient

Without a non-uniform z-profile, resolving a 1.6 mm substrate with a 0.5 mm cell gives only 3 cells — too coarse. Shrinking the uniform cell to 0.2 mm resolves the substrate but inflates cell count by 15x in all three dimensions.

A non-uniform profile uses fine cells inside the substrate (e.g., 0.27 mm) and coarse cells in the air region (e.g., 1.5 mm), saving roughly 5–10x in total cell count.


dz_profile is a 1-D NumPy array of physical z-cell sizes in metres, from z = 0 upward through the physical domain (excluding CPML padding, which rfx adds automatically).

import numpy as np
h = 1.6e-3 # substrate thickness
n_sub = 6 # cells through substrate
dz_sub = h / n_sub # 0.267 mm per substrate cell
margin = 30e-3 # air region above substrate
dz_air = 1.5e-3 # coarse air cells
n_air = int(round(margin / dz_air))
dz_profile = np.concatenate([
np.full(n_sub, dz_sub), # fine: substrate
np.full(n_air, dz_air), # coarse: air
])

!!! tip Choose n_sub so that dz_sub ≤ dx / 2. At least 4 substrate cells are recommended for accurate dispersion. 6–8 cells typically give under 0.5 % resonance error.

For structures where abrupt fine-to-coarse transitions cause numerical reflections, use make_z_profile() to insert a smooth grading:

from rfx.nonuniform import make_z_profile
dz_profile = make_z_profile(
features=[0.0, h], # z-positions that must align to cell boundaries
domain_z=h + margin,
dx_fine=dz_sub,
dx_coarse=dz_air,
grading=1.4, # max ratio between adjacent cells
)

Adjacent cells differing by more than ~1.5x introduce grid dispersion at the transition; keep grading ≤ 1.4.


from rfx import Simulation, Box, GaussianPulse
import numpy as np
h = 1.6e-3
margin = 30e-3
n_sub = 6
dz_sub = h / n_sub
n_air = 20
dz_air = margin / n_air
dz_profile = np.concatenate([np.full(n_sub, dz_sub), np.full(n_air, dz_air)])
sim = Simulation(
freq_max=4e9,
domain=(0.10, 0.08, 0.0), # Lz=0 is OK — replaced by sum(dz_profile)
boundary="cpml",
cpml_layers=12,
dx=5e-4,
dz_profile=dz_profile,
)

When dz_profile is provided, domain[2] is replaced by sum(dz_profile) automatically. Passing domain[2]=0 is a convenient way to signal this.


auto_configure() inspects the geometry for thin z-features and automatically builds a dz_profile when warranted:

from rfx import auto_configure, Simulation, Box
geometry = [
(Box((0, 0, 0), (0.10, 0.08, 1.6e-3)), "fr4"),
(Box((0, 0, 0), (0.10, 0.08, 0)), "pec"), # ground plane
]
cfg = auto_configure(
geometry=geometry,
freq_range=(1e9, 4e9),
materials={"fr4": {"eps_r": 4.4, "sigma": 0.025}},
accuracy="standard",
)
print(cfg.summary())
# SimConfig (accuracy='standard'):
# dx = 0.500 mm (20 cells/lambda_min)
# dz = 0.267 – 1.500 mm (26 cells, non-uniform)
if cfg.uses_nonuniform:
print("Non-uniform z activated")
sim = Simulation(**cfg.to_sim_kwargs())

SimConfig.uses_nonuniform is True when dz_profile is not None.


The timestep is set by the minimum cell size in the entire grid (including CPML padding cells), following the 3-D Courant-Friedrichs-Lewy condition:

dt = 0.99 / (c * sqrt(1/dx^2 + 1/dy^2 + 1/dz_min^2))

A fine substrate cell dz_sub = 0.267 mm with dx = dy = 0.5 mm gives:

dt ~= 0.99 / (c * sqrt(4 + 4 + 14)) ~= 0.64 ps

compared to dt ~= 0.96 ps for a uniform 0.5 mm grid. The non-uniform grid pays ~1.5x more timesteps, but saves ~10x in cells — a net win of ~7x for typical patch antenna problems.


Use Simulation.mesh_intelligence_report(...) before running a memory-constrained non-uniform case. It compares the configured grid against a uniform-fine grid at the smallest configured cell size, carries preflight issues, and can include the segmented-AD estimate used by the non-uniform scan path.

plan = sim.plan_ad_memory(n_steps=10_000, available_memory_gb=24.0)
report = sim.mesh_intelligence_report(
n_steps=10_000,
checkpoint_every=plan.checkpoint_every,
available_memory_gb=24.0,
)
print(plan.recommendation)
print(report.cell_savings_factor)
print(report.recommendation)
# Store this alongside validation or experiment artifacts.
plan_json = plan.to_json()
report_json = report.to_json()

Use plan_mesh(...) when deriving a mesh from geometry, or Simulation.plan_mesh(...) before running an already configured memory-constrained non-uniform case. The returned MeshPlan carries the non-uniform profile audit (nominal_dx, dx_min/dx_max, dy_min/dy_max, dz_min/dz_max, and profiles_present), the configured memory summary, optional S-parameter support checks, and declaration-only artifact paths. Supplying artifact_root names intended mesh-plan/report outputs but does not create directories or write files; scene and replay remain unclaimed until dedicated exporters exist.

MeshPlan is still a planning artifact rather than physics validation. Resolve any support checks, then verify the observable against the appropriate uniform or external reference lane.

The same planning artifact can be generated from the command line with:

Terminal window
python scripts/memory_reduction_planning_artifact.py --available-memory-gb 24.0

For validated work, treat the report as a planning artifact rather than a physics validation by itself: resolve any preflight_issues, then verify the observable against the appropriate uniform or external reference lane.


When building sources for the non-uniform grid at the low level, use make_current_source to correctly normalise the current injection to the local cell volume:

from rfx.nonuniform import make_current_source, make_nonuniform_grid
import numpy as np
grid = make_nonuniform_grid(
domain_xy=(0.10, 0.08),
dz_profile=dz_profile,
dx=5e-4,
cpml_layers=12,
)
# Convert physical position to grid index
from rfx.nonuniform import z_position_to_index
iz = z_position_to_index(grid, z_phys=h / 2)
src = make_current_source(
grid,
ix=grid.nx // 2,
iy=grid.ny // 2,
iz=iz,
component="ez",
)

!!! note The high-level Simulation.add_source() handles this normalisation automatically. make_current_source is only needed when using the low-level run_nonuniform() API directly.


make_nonuniform_grid() returns a NonUniformGrid NamedTuple:

FieldShapeDescription
nx, ny, nzintGrid dimensions including CPML
dx, dyfloatUniform x/y cell size (m)
dz(nz,)Per-cell z sizes including CPML padding
dtfloatTimestep from min-cell CFL (s)
cpml_layersintCPML cells per face
inv_dx(nx,)1/dx, pre-computed for E-curl update
inv_dz(nz,)1/dz[k], pre-computed
inv_dz_h(nz,)2/(dz[k]+dz[k+1]), for H-curl update

All inverse-spacing arrays are stored as jnp.float32 for GPU efficiency.