# Non-Uniform Mesh

Channel: development; package: 2.0.0rc1; source: 2c32b7cf79b796fae825bfa08fc73d9914e61723

Page: https://remilab.ai/rfx/model/domain-mesh/
Source: https://github.com/bk-squared/rfx/blob/2c32b7cf79b796fae825bfa08fc73d9914e61723/docs/public/model/domain-mesh.mdx

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.

| Situation | Mesh |
|---|---|
| substrate much thinner than a wavelength | graded z |
| a stack of several thin layers | graded z, one fine band per layer |
| patch or trace edges whose size matters | in-plane profiles from `edge_aware_profiles`, required unless `snap="declared"` |
| bulk 3-D structure, no thin features | uniform |
| far field or RCS of a bulk object | uniform |

## 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:

```python
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

```python
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:

```python
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:

```python
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](https://remilab.ai/rfx/model/materials/#freeze-the-mesh-before-placing-geometry-from-it)
for placing geometry on an automatic mesh.

## Time step and cost

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

```text
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:

```python
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

| Feature | Graded mesh |
|---|---|
| point sources, probes | yes |
| lumped RLC elements | in the field update; no S-parameters |
| NTFF far field | limited: one dipole case checked against theory |
| DFT planes, flux monitors | run; no general accuracy statement |
| wire-port S-parameters (`add_port(..., extent=...)`) | experimental; compare with a uniform run |
| one-cell lumped-port S-parameters | refused |
| `compute_msl_s_matrix` | experimental; `mode="eigenmode"` refused |
| `compute_waveguide_s_matrix` | single-mode, `normalize=True` or `"flux"` |
| coaxial calculators | refused |
| TFSF plane wave | normal incidence along ±x only |
| Floquet ports, UPML | refused |

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](https://github.com/bk-squared/rfx/blob/2c32b7cf79b796fae825bfa08fc73d9914e61723/docs/guides/support_matrix.md).
