This page shows how to get radiation patterns and directivity from a near-to-far-field transform (NTFF), and how to compute radar cross section (RCS) with a plane wave. It also says which RCS numbers you can trust.
Radiation pattern
An NTFF box is a closed surface around your radiator. During the run rfx
accumulates the tangential E and H on its six faces at the frequencies you
ask for. Afterwards compute_far_field turns those into equivalent currents
and integrates them to the far field. The pattern therefore comes from the
recorded surface, not from probes in the near field.
import numpy as npfrom rfx import GaussianPulse, Simulation, compute_far_field, radiation_pattern
sim = Simulation(freq_max=5e9, domain=(0.20, 0.20, 0.20), dx=4e-3, boundary="cpml", cpml_layers=8)sim.add_source((0.10, 0.10, 0.10), "ez", waveform=GaussianPulse(f0=3e9, bandwidth=0.5), amplitude_kind="current")
# The box encloses the source with about half a wavelength of clearance.sim.add_ntff_box(corner_lo=(0.045, 0.045, 0.045), corner_hi=(0.155, 0.155, 0.155), freqs=np.array([3e9]))
# A probe away from the source lets rfx check that the fields rang down.sim.add_probe((0.12, 0.10, 0.10), "ez")
report = sim.preflight()if report.by_code("absorber_overlap") or report.by_code("ntff_near_field"): raise RuntimeError("move the NTFF box before running:\n" + report.format())
result = sim.run(n_steps=1000)
theta = np.linspace(0, np.pi, 91) # radiansphi = np.linspace(0, 2 * np.pi, 72, endpoint=False)ff = compute_far_field(result.ntff_data, result.ntff_box, result.grid, theta, phi)
pattern_db = radiation_pattern(ff) # (n_freqs, n_theta, n_phi), peak = 0 dBcompute_far_field returns a FarFieldResult with E_theta, E_phi,
theta, phi and freqs. Angles are in radians. The mesh here is coarse
for speed; see Limits for what a real pattern needs.
The far field is a Fourier transform of the recorded surface fields, so the run must last until the fields have died out. rfx judges that from your probe records: when a run has an NTFF box, it warns if a probe is still ringing at the end, and warns that the check is missing if you added no probe. Put the probe where the field is live but not on the source cell.
Placing the box
- Put every face between the radiator and the absorber, with clearance from
both. Preflight’s
ntff_near_fieldcheck warns when a face is closer than λ/2 to a source, port or tangentially overlapping geometry, and more strongly below λ/4.absorber_overlapfires when the box reaches the absorber. - The near-field check does not see plane-wave (TFSF) boundaries or microstrip feed positions. Check those clearances yourself.
- A small ground plane gives an
ntff_small_ground_planeadvisory when it is under about 1 λ across. Edge diffraction then shapes the pattern (a broadside dip, off-axis peaks). That is real physics, not a solver error; use a ground plane of about 1.4 λ or more if you want a clean broadside pattern.
Directivity
from rfx import directivity
D = directivity(ff) # (n_freqs,) in dBiprint(f"Directivity: {D[0]:.2f} dBi") # a short dipole is about 1.76 dBidirectivity integrates the radiated power over the whole sphere
(D = 4π U_max / P_rad). Sample theta over [0, π] and phi over [0, 2π), as
above. A single cut gives the wrong P_rad.
For optimization, compute_far_field_jax(..., max_phase_bytes=4e9) is the
differentiable version. The byte budget splits the theta grid into chunks to
bound memory; the result is the same. For a directivity objective use
maximize_directivity(theta_target, phi_target, log_ratio=True), so the
gradient sees changes in total radiated power. See
Inverse Design.
Radar cross section
compute_rcs illuminates a target with a plane wave travelling along +x,
records the scattered field on an NTFF box, and returns
RCS(θ, φ) = 4π r² |E_scat|² / |E_inc|². It is a functional API: you pass a
Grid and MaterialArrays holding the target, not a Simulation.
import jax.numpy as jnpfrom rfx import Box, Grid, compute_rcsfrom rfx.core.yee import MaterialArraysfrom rfx.geometry.csg import rasterize
f0 = 3e9 # lambda = 0.1 mdx = 0.01 # lambda / 10: coarse, for speedgrid = Grid(freq_max=f0 * 1.5, domain=(0.12, 0.12, 0.12), dx=dx, cpml_layers=8)
# A 4 cm square plate, one cell thick, normal to x. compute_rcs takes material# arrays, so the plate is a high-conductivity fill (1e7 S/m), not a declared# PEC conductor.c = 0.06plate = Box((c - dx / 2, c - 0.02, c - 0.02), (c + dx / 2, c + 0.02, c + 0.02))eps_r, sigma = rasterize(grid, [(plate, 1.0, 1e7)])materials = MaterialArrays(eps_r=eps_r, sigma=sigma, mu_r=jnp.ones(grid.shape, dtype=jnp.float32))
rcs = compute_rcs( grid, materials, n_steps=400, f0=f0, bandwidth=0.5, polarization="ez", theta_obs=np.linspace(0.01, np.pi - 0.01, 91), phi_obs=np.array([0.0, np.pi / 2]), freqs=np.array([f0]), subtract_incident_reference=True, # needed for the bistatic pattern)
print(f"Monostatic RCS: {rcs.monostatic_rcs[0]:.1f} dBsm")print(f"Bistatic range: {rcs.rcs_dbsm.min():.1f} to {rcs.rcs_dbsm.max():.1f} dBsm")RCSResult holds rcs_dbsm and rcs_linear with shape
(n_freqs, n_theta, n_phi), the freqs / theta / phi axes, and
monostatic_rcs (dBsm, one value per frequency). The monostatic value is
evaluated exactly at the backscatter direction (θ = π/2, φ = π for +x
incidence), so it does not depend on the observation grid you pass.
Which RCS numbers to trust
Monostatic (backscatter). monostatic_rcs is the quantity checked
against the exact Mie series for a PEC sphere at ka ≈ 1 (λ/40 cells). On that
committed case it reads 0.73 dB above Mie. It is always taken from the plain
run, whatever you set for subtract_incident_reference.
Bistatic. With the default subtract_incident_reference=False, the
bistatic pattern is not validated: off-backscatter angles can be several
dB to about 20 dB wrong, typically as a spurious forward-oblique lobe. The
reason is geometric. The plane wave is injected between two x planes, so the
region carrying the incident field is a slab that is infinite in y and z. The
side faces of any closed NTFF box cut through that slab and record the
incident field at full strength. Moving the box with a larger ntff_offset
does not help, because every closed box has side faces in the slab. An empty
run with no target shows the same lobe.
Set subtract_incident_reference=True for bistatic work. rfx then repeats
the run without the target and subtracts the two far fields (as complex
values) before forming the RCS, which removes the incident-field
contribution. It doubles the cost. On the Mie sphere it removes most of the
forward-oblique lobe; that comparison covers one sphere, frequency,
polarization and cut, not every target.
After subtraction, the remaining error comes from staircasing of curved
surfaces, sensitivity near deep pattern nulls, box placement and absorber
reflection. For a new target: refine the mesh, lengthen the run, vary
ntff_offset and cpml_layers, and compare with an analytic or independent
result.
RCSResult does not record whether subtraction was used, so keep your call
settings with any saved result.
Box placement and oblique incidence
compute_rcs places the plane-wave boundary tfsf_margin cells in from the
absorber and the NTFF box ntff_offset cells outside the plane-wave
boundary. The box must enclose the whole injected region, and rfx refuses to
run otherwise, naming the faces that don’t clear it.
- Normal incidence (
theta_inc=0, the default): the defaults (tfsf_margin=3,ntff_offset=1) are valid. - Oblique incidence (
theta_incin degrees, tilted in the x-y plane): this uses an open-domain plane wave forpolarization="ez"on a uniform grid only, with z-invariant targets. Its y faces are inset from the absorber, not from the injection planes, so the box encloses the injected region only for1 <= ntff_offset <= tfsf_margin - 2. Raisetfsf_marginif you need a larger offset. At oblique incidence the direction of the specular peak is checked; treat the absolute RCS as accurate to about ±2 dB. The oblique model is 2.5-D (periodic in z), so the returned RCS belongs to a strip of height 2dx; scale by (L_z / (2 dx))² for a strip of height L_z.
If you build an NTFFBox yourself around a plane-wave source, it too must
surround the injected region; rfx refuses a box that doesn’t.
Complex scattered field with a phase reference
For the scattered field’s phase as well as its magnitude, pass
phase_reference=(x, y, z) together with subtract_incident_reference=True.
compute_rcs then returns a ScatteringResponse instead of an RCSResult:
complex far-field amplitudes F_theta and F_phi in metres, the incident
spectrum, and the physical point the phase is referenced to. For now this
works on a uniform 3-D grid with symmetric CPML and normal incidence along
+x, for either transverse polarization; other layouts are refused. The
reference’s x must lie on an E-node plane inside the total-field slab (rfx
refuses rather than snaps it), and only frequencies where the incident
spectrum is nonzero are accepted. The phase
is only as good as the record length, source bandwidth and mesh, so check
convergence as you would for the magnitude.
Plotting
from rfx import plot_radiation_pattern, plot_rcs
plot_radiation_pattern(ff, freq_idx=0, phi_idx=0) # E-plane cut at phi = 0plot_rcs(rcs, freq_idx=0, polar=True) # polar cut, phi_idx = 0plot_rcs(rcs, freq_idx=0, polar=False) # dBsm vs angleBoth helpers return a matplotlib figure for one frequency and one φ cut. See Visualization & Result Analysis for the other helpers.
Limits
- Pattern changes when you run longer → the record was cut. The
ring-down check reads your probes, not the NTFF surface, so it is a guide,
not a proof. Repeat with a longer run (or
until_decay=) and compare the patterns. - Directivity looks wrong → sample the full sphere; a single cut gives the wrong total power.
- Box too close to the radiator or touching the absorber → preflight warns; move the faces out. It does not check plane-wave boundaries or microstrip feeds for you.
- Bistatic RCS with a strong forward-oblique lobe → you left
subtract_incident_reference=False; turn it on. A largerntff_offsetdoes not fix it. - RCS of a new target → only the monostatic sphere case is checked against Mie. Run your own convergence study and compare with a reference.
- Oblique incidence refuses your setup → it needs
polarization="ez", a uniform grid, a z-invariant target andntff_offset <= tfsf_margin - 2. - Reporting an RCS → state the incident direction, polarization, observation angles and frequency band with it.
The support matrix lists the current status of each far-field and RCS path.
