# Non-Uniform Mesh

Channel: release; package: 1.8.0; source: 7d1e97331656dd74ad0f5471653ac0c5effdff70

Page: https://remilab.ai/rfx/versions/v1.8.0/guide/nonuniform-mesh/
Source: https://github.com/bk-squared/rfx/blob/7d1e97331656dd74ad0f5471653ac0c5effdff70/docs/public/guide/nonuniform-mesh.mdx

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

- 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 mesh (`dx_profile`/`dy_profile`/`dz_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). A `dz_profile` routes to the same non-uniform lane (dispatch fixed in #811; before that, dz-only simulations silently solved on the uniform grid) — dz-graded *accuracy* is unvalidated pending the multi-band promotion tracker (#810). 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. The non-uniform lane carries the same ring-down settling witness (`settling_db`, -40 dB warning) as the uniform lane. |
| 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

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

`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

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

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

```python
from rfx import smooth_grading
from 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`

```python
from rfx import Box, Simulation
import numpy as np

h = 1.6e-3
n_sub = 6
lower_air = np.full(24, 8e-3 / 24)   # 0.333 mm cells
substrate = np.full(n_sub, h / n_sub)  # 0.267 mm cells
upper_air = np.full(90, 30e-3 / 90)  # 0.333 mm cells
dz_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

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

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

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

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.

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

```bash
python scripts/memory_reduction_planning_artifact.py \
  --n-steps 10000 \
  --available-memory-gb 8.0 \
  --output /tmp/rfx-memory-plan.json
```

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

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.

---
