Non-Uniform Mesh
rfx supports a non-uniform (graded) Yee grid. This guide covers the common
case — a graded z-profile (dz_profile) with uniform dx and dy — for thin
layered structures. dx_profile and dy_profile can grade the transverse axes,
but their boundary-cell and domain-extent contracts differ from dz_profile.
For concrete arrays, both dx_profile endpoints must equal the scalar dx,
while the two concrete dy_profile endpoints must equal each other. For traced
profiles, the caller must keep both dx_profile and dy_profile endpoints
equal to the scalar dx. Domain-extent behavior is described with the setup
example below. Use the uniform Cartesian Yee grid first. Choose a graded grid
only when a thin feature would make a uniform grid impractical, and validate
the final RF observable against a uniform or external reference.
Support status
Section titled “Support status”- suitable for thin-substrate or layered-z RF setup studies when the observable remains inside its documented limits;
- memory and mesh reports are planning artifacts, not physics validation;
- most unsupported combinations raise explicit errors rather than silently dropping features; a few (NTFF, DFT-plane probes, full-plane flux monitors) instead run on the graded-z path but are not part of the validated default — see the table below.
Unsupported or restricted combinations:
| Combination | Public status |
|---|---|
| periodic (Floquet) ports + non-uniform z mesh | unsupported (raises) |
| single-cell lumped-port S-parameters + non-uniform z mesh | unsupported (raises); nonuniform add_port(..., extent=...) WirePort extraction is experimental, so compare it with the uniform calculation or use a uniform grid |
| coaxial-port S-parameters + non-uniform z mesh | unsupported (raises) |
| lumped-RLC + non-uniform z mesh | runs as a field ADE element (time-domain fields only); no S-parameter output — not a S-param workflow and does not raise |
compute_msl_s_matrix() + non-uniform mesh | mode='laplace'/'uniform' only (the add_msl_port default); mode='eigenmode' raises |
compute_waveguide_s_matrix() + non-uniform transverse mesh (dx_profile/dy_profile) | single-mode ports with normalize=True or normalize='flux'; one stored Palace comparison covers normalize='flux', a graded-dy ratio of 2, WR-90 empty/PEC-short/dielectric-slab cases, and magnitude over 8.2—12.4 GHz (max_mag_abs_diff=0.008529, previously 0.07009 on an under-provisioned absorber). Treat other profiles, bands, modes, phase, and junctions as unvalidated. Differentiable eps_override/sigma_override requires flux normalization. Only eps_override has a nonuniform AD-vs-FD regression test; neither gradient claim is RF validation. |
| TFSF plane-wave source + non-uniform z mesh | normal incidence along x only (direction='+x'/'-x', angle_deg=0); ±z or oblique incidence raises |
finite-region flux monitor (add_flux_monitor(size=...)) + non-uniform z mesh | unsupported (raises); use a full-plane monitor (omit size=) |
NTFF boxes, DFT-plane probes, and full-plane flux monitors run on the graded-z path, but their observables must be validated against the uniform or external reference before any public claim.
When to use a non-uniform mesh
Section titled “When to use a non-uniform mesh”| Situation | Recommendation |
|---|---|
| Substrate much thinner than wavelength (e.g., 1.6 mm FR4 at 2.4 GHz, lambda = 125 mm) | Evaluate a non-uniform z mesh, then validate the final observable |
| Layer stack with multiple thin dielectric sheets | Non-uniform z can reduce cell count; keep support checks visible |
| Bulk 3-D structure with no thin z-features | Uniform grid |
| RCS / far-field only | Uniform 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 refines all three axes at once (2.5x per axis), inflating the total cell count by roughly 15x.
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). The resulting cell-count reduction is setup-dependent; record the mesh report and verify the RF observable, rather than treating the mesh saving itself as validation evidence.
Constructing dz_profile
Section titled “Constructing dz_profile”dz_profile is a 1-D NumPy array of physical z-cell sizes in meters, from z = 0 upward through the physical domain (excluding CPML padding, which rfx adds automatically).
Manual construction
Section titled “Manual construction”import numpy as np
h = 1.6e-3 # substrate thicknessn_sub = 6 # cells through substratedz_sub = h / n_sub # 0.267 mm per substrate cell
margin = 30e-3 # air region above substratedz_air = 1.5e-3 # coarse air cellsn_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 Resolve the substrate with at least 4 cells as a practical starting point; use 6–8 when the in-substrate field variation matters. Then verify the resonance or S-parameter observable for the specific geometry.
Graded transition
Section titled “Graded transition”Avoid abrupt fine-to-coarse jumps; adjacent-cell ratios above 1.3x can introduce
numerical reflections or dispersion. To generate a graded profile from scratch,
use make_z_profile from rfx.nonuniform:
from rfx import smooth_gradingfrom rfx.nonuniform import make_z_profile
candidate = 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.3,)dz_profile = smooth_grading(candidate, max_ratio=1.3)The final fill cell from make_z_profile() can still exceed the requested
adjacent-cell ratio. smooth_grading() repairs that ratio, but it may change the
profile length, total domain height, or a requested feature edge. Inspect the
final cumulative edges after either operation. Keep grading conservative,
record the generated profile, and verify the RF observable against the relevant
reference.
Passing dz_profile to Simulation
Section titled “Passing dz_profile to Simulation”from rfx import Box, Simulationimport numpy as np
h = 1.6e-3n_sub = 6lower_air = np.full(24, 8e-3 / 24) # 0.333 mm cellssubstrate = np.full(n_sub, h / n_sub) # 0.267 mm cellsupper_air = np.full(90, 30e-3 / 90) # 0.333 mm cellsdz_profile = np.concatenate((lower_air, substrate, upper_air))
# Geometry boundaries must coincide with the final profile.z_edges = np.concatenate(([0.0], np.cumsum(dz_profile)))substrate_lo = z_edges[len(lower_air)]substrate_hi = z_edges[len(lower_air) + n_sub]assert np.isclose(substrate_hi - substrate_lo, h)
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,)sim.add( Box((0.01, 0.01, substrate_lo), (0.09, 0.07, substrate_hi)), material="fr4",)
# Run preflight after Simulation and geometry are configured.report = sim.preflight()for issue in report.by_code("graded_box_rasterization"): print(issue)# Add the source and observables, then resolve the full report before running.In the example above, domain[2]=0 tells the constructor to replace the z
extent with sum(dz_profile). If a positive domain[2] is supplied, it is
retained; rfx does not replace it or establish that it equals the profile sum.
Set it explicitly to float(np.sum(dz_profile)) and verify the equality when
the profile is assembled separately. Concrete dx_profile and dy_profile
extents are synthesized from their profile sums. Traced transverse profiles
cannot be host-summed, so the caller must supply positive concrete
domain[0] / domain[1] extents and keep them consistent with the profiles.
Transition insertion changes the final cell sequence. A fine band requested
near a thin Box can otherwise shift outside the box span, leaving the material
with fewer cells than its requested fine spacing suggests. Inspect cumulative
edges as shown above and run preflight after registering geometry.
The graded_box_rasterization advisory reports the actual and implied z-cell
counts when a Box occupies at most four cells and less than half of the count
implied by nearby fine spacing. Align the material boundary to a final profile
edge or rebuild the grading region; do not dismiss the warning based only on
the pre-grading request.
auto_configure detection
Section titled “auto_configure detection”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"),]
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 = 1.500 mm (50 cells/λ_min)# ... # domain / cpml / n_steps / freq / source lines# dz = 0.133 – 1.499 mm (67 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.
This example plans from the dielectric thickness only. A zero-thickness
Box does not rasterize a conductor; register a physical sheet separately
with sim.add_thin_conductor(...) after constructing the simulation, or give a
volumetric conductor a finite, cell-aligned thickness.
CFL timestep from minimum cell
Section titled “CFL timestep from minimum cell”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))For the auto-configured example above, dz_min = 0.133 mm with
dx = dy = 1.5 mm gives approximately:
dt ~= 0.99 / (c * sqrt(0.44 + 0.44 + 56.5)) ~= 0.44 ps(the terms are 1/dx^2, 1/dy^2, and 1/dz_min^2 in mm^-2),
compared with about 2.86 ps for a uniform 1.5 mm grid. The non-uniform grid
pays more timesteps because the smallest cell controls CFL; whether it is faster
overall depends on the configured domain, material, monitors, and validation run.
Mesh and AD memory planning artifact
Section titled “Mesh and AD memory planning artifact”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=8.0)report = sim.mesh_intelligence_report( n_steps=10_000, checkpoint_every=plan.checkpoint_every, available_memory_gb=8.0,)
print(plan.recommendation)print(report.cell_savings_factor)print(report.recommendation)
# Store this alongside validation or local-run artifacts.plan_json = plan.to_json()report_json = report.to_json()Use the module-level rfx.plan_mesh(geometry, freq_range, ...) 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 in its cell_sizes block (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; the
scene and replay declarations stay not_claimed until dedicated exporters
exist.
From a source checkout, the same planning artifact can be generated without
running FDTD. The scripts/ path is not installed with the wheel:
python scripts/memory_reduction_planning_artifact.py \ --n-steps 10000 \ --available-memory-gb 8.0 \ --output /tmp/rfx-memory-plan.jsonBoth MeshPlan and the mesh-intelligence report describe the planned setup;
they do not validate the physics. Resolve every preflight_issues entry and
support check, then verify the observable against an appropriate uniform-grid
or external reference.
Low-level API
Section titled “Low-level API”For advanced use, run_nonuniform and make_current_source (both in rfx.__all__)
expose the non-uniform field-update loop and cell-volume-normalized source
injection directly, bypassing Simulation. See rfx/nonuniform.py for their
signatures. Use make_current_source when constructing the low-level source
array directly; the high-level Simulation path performs its own source
registration and scaling.
Soft-source amplitude semantics: on this path the add_source waveform is
natively a current in amperes (E += Cb·I/dV, resolution-independent
injected power). Pass add_source(..., amplitude_kind='current') to say so
explicitly — the bare per-path default is deprecated (issue #571), and an
explicit amplitude_kind means the same amplitude on the uniform and
non-uniform builders.