REMI Lab
OPEN KNOWLEDGE / TOOLS

Non-Uniform Mesh

이 페이지의 내용

A graded mesh uses small cells only where the geometry needs them, such as a thin substrate, and large cells in the air around it. This page shows how to build a cell-size profile, pass it to Simulation, and check that your geometry lands where you drew it.

Start with a uniform grid. Switch to a graded one when a thin feature would make a uniform grid too large, and then compare the final result against a uniform run or another reference.

When a graded mesh pays off

A 1.6 mm substrate at 0.5 mm cells gets only 3 cells, which is too coarse. Shrinking a uniform cell to 0.2 mm resolves it, but it refines all three axes: about 15× more cells. A graded z profile puts 0.27 mm cells in the substrate and 1.5 mm cells in the air, and leaves x and y alone.

SituationMesh
substrate much thinner than a wavelengthgraded z
a stack of several thin layersgraded z, one fine band per layer
patch or trace edges whose size mattersin-plane profiles from edge_aware_profiles, required unless snap="declared"
bulk 3-D structure, no thin featuresuniform
far field or RCS of a bulk objectuniform

Building a z profile

A dz_profile is a 1-D array of cell sizes in metres, from z = 0 upward. It covers the physical domain; rfx adds the absorber cells itself.

The easiest way to build one is make_band_profile. Give it every interface, including both domain ends, and a target cell size per segment:

import numpy as np
from rfx.nonuniform import make_band_profile
h = 1.6e-3 # substrate thickness
below = 20e-3 # air below the substrate
above = 30e-3 # air above it
dz_profile = make_band_profile(
edges=[0.0, below, below + h, below + h + above],
cell_sizes=[1.5e-3, h / 6, 1.5e-3], # 6 cells through the substrate
protected=[False, True, False], # keep the substrate uniform
max_ratio=1.3,
)
print(len(dz_profile), dz_profile.min(), dz_profile.max())

The result has every interface on a cell boundary (to 1e-12 m), every neighbouring-cell ratio at most max_ratio, and a sum equal to the domain height exactly. The ramps between sizes are placed in the unprotected segments.

Rules of thumb:

  • At least 4 cells through a substrate; 6–8 when the field inside it matters.
  • Neighbour ratio ≤ 1.4 in z (the validated limit), and ≤ 1.3 in x and y. Preflight warns above that.
  • Interfaces on cell boundaries. make_band_profile does this for you.
  • Uniform cells next to the absorber. Leave room for cpml_layers full-size cells between the last ramp and each absorbing face. Here the 20 mm below and 30 mm above the substrate are enough for 8 layers. Preflight warns (nu_grading_reaches_absorber) when a ramp reaches into that runway.

Two alternatives:

  • make_z_profile(features, domain_z, dx_fine, dx_coarse, grading) puts fine cells at each listed z plane and at both ends, with ramps between.
  • A hand-built array with np.concatenate. If you do this, rfx.smooth_grading() can insert ramp cells, but it shifts every interface above them, so check the cumulative edges afterwards. Neither builder needs it.

Using the profile

from rfx import Box, Simulation
from rfx.mesh_edges import edge_aware_profiles
sub_lo, sub_hi = below, below + h
board = Box((0.01, 0.01, sub_lo), (0.09, 0.07, sub_hi))
ground = Box((0.01, 0.01, sub_lo), (0.09, 0.07, sub_lo))
# This horizontal sheet needs x/y registration; keep the layered z profile.
profiles = edge_aware_profiles(
(0.10, 0.08, float(np.sum(dz_profile))), 1e-3,
sheets=[ground], solids=[board], axes="xy",
)
sim = Simulation(
freq_max=4e9,
domain=(0.10, 0.08, 0.0), # z = 0: use sum(dz_profile)
dx=1e-3,
dz_profile=dz_profile,
**profiles,
boundary="cpml",
cpml_layers=8,
)
z_edges = np.concatenate(([0.0], np.cumsum(dz_profile)))
assert np.isclose(z_edges, sub_lo).any() and np.isclose(z_edges, sub_hi).any()
sim.add(board, material="fr4")
sim.add_thin_conductor(ground)
report = sim.preflight()
print(report.format())

domain[2] = 0 tells rfx to use sum(dz_profile) as the height. A positive value is kept as given and is not checked against the profile, so pass float(np.sum(dz_profile)) if you set it yourself.

Keep the interfaces on node planes, as the assert above checks. A sheet conductor lands on the nearest node plane, so a substrate face between nodes leaves a gap between the copper and the dielectric. Preflight’s graded_box_rasterization warning fires when a thin Box gets far fewer cells than the fine spacing nearby implies; fix the profile rather than the warning.

Grading x and y

dx_profile and dy_profile work the same way, with one extra rule: both end cells must equal the scalar dx, so every absorber face sees the same cell. make_band_profile(..., boundary_cell=dx) does this.

The same uniform-runway rule applies next to each absorbing face. Keep fine bands away from absorbing faces, or close those faces with PEC.

Solving sheets at their drawn size

A PEC sheet’s in-plane edge is solved about 0.35 of a cell beyond its last grid node, so a patch or trace comes out slightly larger than drawn. A PEC sheet whose free-edge size is solved more than 1 % off its drawing is refused by run(), forward() and compute_mixed_s_matrix(). Preflight reports this as a sheet_effective_size error.

Build profiles with edge_aware_profiles to place sheet edges where they solve at their drawn size. Use axes="xyz" and pass the returned dz_profile too for vertical sheets; the horizontal ground above only needs x/y registration and keeps its layered z profile. Alternatively, Simulation(snap="declared") accepts the difference as a warning and records the choice on Simulation.realized_geometry(); it changes neither the mesh nor the solved geometry.

Ends at seams, overlaps and T-joins with other sheet metal, at or past a domain face, or joined to a realized PEC volume are not judged. A gap of one cell or more to a block still leaves a free edge. These rules and the tolerance are implemented in rfx/mesh_edges.py::solved_sheet_span and rfx/preflight/pec_geometry.py::_warn_sheet_effective_size.

For a patch and board:

from rfx.mesh_edges import edge_aware_profiles
LX, LY, LZ = 0.04, 0.03, 0.01
board = Box((0.0, 0.0, 0.0), (LX, LY, 1.6e-3))
patch = Box((0.0105, 0.0083, 1.6e-3), (0.0295, 0.0217, 1.6e-3))
profiles = edge_aware_profiles((LX, LY, LZ), 1e-3, sheets=[patch],
solids=[board])
patch_sim = Simulation(freq_max=6e9, domain=(LX, LY, LZ), dx=1e-3,
boundary="cpml", cpml_layers=8, **profiles)
patch_sim.add(board, material="fr4")
patch_sim.add_thin_conductor(patch)

Draw the geometry at its true size; do not shift it to compensate.

Letting rfx choose the mesh

auto_configure sizes the cell, the domain, the absorber and the run length from your geometry and band, and builds a dz_profile when it finds thin z features:

from rfx import auto_configure
cfg = auto_configure(
geometry=[(Box((0, 0, 0), (0.10, 0.08, 1.6e-3)), "fr4")],
freq_range=(1e9, 4e9),
materials={"fr4": {"eps_r": 4.4, "sigma": 0.025}},
accuracy="standard",
)
print(cfg.summary())
auto_sim = Simulation(**cfg.to_sim_kwargs())

cfg.uses_nonuniform tells you whether it chose a graded z mesh. Its profile can step by more than the 1.4 ratio (it does here, and the Simulation constructor warns), so treat it as a starting point. It plans from the dielectrics you pass. Add conductors to the simulation afterwards, as sheets or as volumes at least one cell thick.

If you create a Simulation without dx, rfx picks a mesh from the registered geometry automatically; see Materials and Geometry for placing geometry on an automatic mesh.

Time step and cost

The time step follows the smallest cell anywhere in the grid:

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

With 1.5 mm cells in x and y and a 0.133 mm minimum in z, that is about 0.44 ps, against 2.86 ps for a uniform 1.5 mm grid. A graded mesh has far fewer cells but needs more steps. Whether it is faster overall depends on the model.

To estimate memory before a run, especially for gradients:

plan = sim.plan_ad_memory(n_steps=10_000, available_memory_gb=8.0)
mesh_report = sim.mesh_intelligence_report(
n_steps=10_000, checkpoint_every=plan.checkpoint_every,
available_memory_gb=8.0)
print(plan.recommendation)
print(f"{mesh_report.cell_savings_factor:.1f}x fewer cells than uniform-fine")

These are planning numbers. They say nothing about accuracy.

What runs on a graded mesh

FeatureGraded mesh
point sources, probesyes
lumped RLC elementsin the field update; no S-parameters
NTFF far fieldlimited: one dipole case checked against theory
DFT planes, flux monitorsrun; no general accuracy statement
wire-port S-parameters (add_port(..., extent=...))experimental; compare with a uniform run
one-cell lumped-port S-parametersrefused
compute_msl_s_matrixexperimental; mode="eigenmode" refused
compute_waveguide_s_matrixsingle-mode, normalize=True or "flux"
coaxial calculatorsrefused
TFSF plane wavenormal incidence along ±x only
Floquet ports, UPMLrefused

Unsupported combinations raise before the first step instead of running with a feature silently dropped. On a graded mesh, run() refuses snapshots and conformal_pec=True, and on a closed (PEC) boundary it refuses until_decay; use a fixed n_steps there.

Limits

  • Accuracy is per observable. A graded run that completes is not evidence that its S-parameters, flux or far field are right. Compare with a uniform run or an external reference.
  • Grading ratios above 1.4 in z, or 1.3 in x and y, are outside the validated range. Preflight warns.
  • TFSF along a graded axis: preflight warns, because the scattered-field region picks up a spurious field (about 0.7 of the total-field peak with no scatterer). Normal incidence along ±x needs a uniform x axis.
  • Microstrip probes on a graded feed: the automatic probe offset and spacing are counted in cells under the port, and recounted at run time. Keep the feed region uniform if you want predictable probe positions.
  • Graded-mesh waveguide results beyond the published WR-90 comparisons are experimental. A dz-only profile has no accuracy evidence yet.
  • Subgridding (add_refinement) is a different tool: it works only through run() on a uniform mesh, and forward(), optimize() and topology_optimize() refuse a refined model.
  • The full per-combination status is in the support matrix.
Back to top ↑