Edit on GitHub

rfx

rfx — JAX-based RF FDTD electromagnetic simulator.

@dataclass(frozen=True)
class Diagnostic:

One finding, with named SI scalars or explanatory strings.

Values are copied and made read-only. Path is absent for checks performed before execution dispatch. The record contains no solver or array objects.

Diagnostic( code: str, severity: Literal['info', 'advisory', 'warning', 'refusal'], subject: str | None, message: str, values: Mapping[str, int | float | str], path: str | None = None, source: str | None = None)
code: str
severity: Literal['info', 'advisory', 'warning', 'refusal']
subject: str | None
message: str
values: Mapping[str, int | float | str]
path: str | None = None
source: str | None = None
legacy_severity: str
def to_dict(self) -> dict:

JSON-safe even for a nonfinite numerical observation.

class Grid:

Rectilinear FDTD grid with uniform cell size.

Supports 3D and 2D (TMz/TEz) modes. In 2D mode the grid has nz=1 and the Courant factor uses √2 instead of √3. The existing 3D update equations naturally reduce to 2D because z-derivatives vanish when the z-axis is periodic with one cell.

Parameters

freq_max : float Maximum simulation frequency (Hz). Used for auto-resolution. domain : tuple[float, float, float] Physical domain size (Lx, Ly, Lz) in meters. For 2D modes, Lz is ignored (set nz=1 internally). dx : float | None Cell size override. If None, auto-computed as λ_min / 20. cpml_layers : int Number of CPML absorbing layers on each face. cpml_axes : str Axes that receive CPML padding. Default "xyz". mode : str "3d" (default), "2d_tmz" (Ez, Hx, Hy), or "2d_tez" (Hz, Ex, Ey).

Grid( freq_max: float, domain: tuple[float, float, float], dx: float | None = None, cpml_layers: int = 8, cpml_axes: str = 'xyz', mode: str = '3d', kappa_max: float | None = None, pec_faces: set[str] | None = None, pmc_faces: set[str] | None = None, face_layers: dict | None = None, conformal_faces: set[str] | None = None, periodic_axes: str = '')
freq_max
domain
cpml_layers
kappa_max
pec_faces
pmc_faces
conformal_faces
cpml_axes
mode
is_2d
periodic_axes
dx
dt
boundary_depths
face_layers
pad_x
pad_y
pad_z
axis_pads
face_pads
nx
ny
shape
@staticmethod
def courant_dt(dx: float, ndim: int = 3) -> float:

Courant-stable timestep for 2D or 3D FDTD.

def num_timesteps(self, num_periods: float = 20.0) -> int:

Estimate timesteps for given number of periods at freq_max.

def position_to_index(self, pos: tuple[float, float, float]) -> tuple[int, int, int]:

Convert physical position to grid index (accounting for the leading per-face CPML offset pad_{axis}_lo).

Raises

ValueError If pos maps outside the grid. The pre-guard code returned an out-of-range index silently, which then indexed the wrong cell (or wrapped negatively) downstream.

def interval_to_indices(self, start, end):

Map a cell/edge extent without wrapping its exclusive high endpoint.

A constant coordinate denotes a plane and uses the point lookup. Non-periodic axes retain position_to_index's input-dtype division.

def cells(self, axis) -> numpy.ndarray:

PRIMAL cell widths along axis, float64, full padded length.

Constant by construction: np.full(n, dx). The last entry is the fence-post node-provider (see rfx/_grid_metric.py), so the sum overshoots the realized extent by one cell -- use node_of.

def duals(self, axis) -> numpy.ndarray:

DUAL (E-node) spacings along axis, float64.

On a constant axis dual[k] = (d[k-1]+d[k])/2 = d[k] exactly, and dual[0] = d[0] by the same rule, so this equals cells bit for bit. Spelled through the shared helper anyway so the two classes cannot drift apart in how the rule is written.

def index_of(self, axis, x: float) -> int:

Padded index of the node nearest physical coordinate x.

Divides in float64 before applying the shared lower-node tie rule. position_to_index retains its input scalar's division dtype. Declared periodic axes wrap modulo their node count. In 2-D mode the z axis holds one cell and the answer is always 0, which is what position_to_index returns there.

def node_of(self, axis, i: int) -> float:

Physical coordinate of the E node at padded index i.

(i - pad_lo) * dx -- the closed form _uniform_axis_nodes (rfx.geometry.rasterize_grid._uniform_axis_nodes) evaluates for the whole axis, so this equals coords_from_uniform_grid(grid).<axis>[i] bit for bit, and equals the bare i * dx on an axis with no pad.

def boundary_cell(self, axis, side: str) -> float:

The cell width at the "lo" or "hi" face of axis.

The number a CPML profile is entitled to calibrate against. Constant here, so both sides give dx.

def is_constant(self, axis) -> bool:

Whether every cell on axis has the same width.

Always True on this class. Decision 4 selects the uniform kernel on this predicate rather than on "was a profile given", so that the kernel and the metric have one source.

def is_traced(self, axis) -> bool:

Whether axis's cell widths are a JAX tracer (decision 6).

Always False here: a uniform Grid holds a Python float.

class NonUniformGrid(typing.NamedTuple):

Non-uniform grid with per-axis cell-size arrays.

dx / dy are the BOUNDARY cell sizes (used by CPML and any legacy code that reads a scalar spacing); dx_arr / dy_arr / dz hold the per-cell spacings. In the uniform-xy case, dx_arr is jnp.full(nx, dx) and dy_arr is analogous.

pad_{axis}_{lo|hi} mirror rfx.grid.Grid per-face padding — a face whose token is pmc/pec/periodic gets 0 cells on that side even when the axis as a whole uses CPML. For back-compat the six fields default to cpml_layers on every face (the pre-per-face-allocation symmetric layout).

NonUniformGrid( nx: int, ny: int, nz: int, dx: float, dy: float, dx_arr: ForwardRef('jnp.ndarray'), dy_arr: ForwardRef('jnp.ndarray'), dz: ForwardRef('jnp.ndarray'), dt: float | jax.jaxlib._jax.Array, cpml_layers: int, inv_dx: ForwardRef('jnp.ndarray'), inv_dy: ForwardRef('jnp.ndarray'), inv_dz: ForwardRef('jnp.ndarray'), inv_dx_h: ForwardRef('jnp.ndarray'), inv_dy_h: ForwardRef('jnp.ndarray'), inv_dz_h: ForwardRef('jnp.ndarray'), pad_x_lo: int = 0, pad_x_hi: int = 0, pad_y_lo: int = 0, pad_y_hi: int = 0, pad_z_lo: int = 0, pad_z_hi: int = 0, dx_arr_f64: ForwardRef('np.ndarray | None') = None, dy_arr_f64: ForwardRef('np.ndarray | None') = None, dz_f64: ForwardRef('np.ndarray | None') = None, face_layers: dict[str, int] | None = None)

Create new instance of NonUniformGrid(nx, ny, nz, dx, dy, dx_arr, dy_arr, dz, dt, cpml_layers, inv_dx, inv_dy, inv_dz, inv_dx_h, inv_dy_h, inv_dz_h, pad_x_lo, pad_x_hi, pad_y_lo, pad_y_hi, pad_z_lo, pad_z_hi, dx_arr_f64, dy_arr_f64, dz_f64, face_layers)

nx: int

Alias for field number 0

ny: int

Alias for field number 1

nz: int

Alias for field number 2

dx: float

Alias for field number 3

dy: float

Alias for field number 4

dx_arr: jax.jaxlib._jax.Array

Alias for field number 5

dy_arr: jax.jaxlib._jax.Array

Alias for field number 6

dz: jax.jaxlib._jax.Array

Alias for field number 7

dt: float | jax.jaxlib._jax.Array

Alias for field number 8

cpml_layers: int

Alias for field number 9

inv_dx: jax.jaxlib._jax.Array

Alias for field number 10

inv_dy: jax.jaxlib._jax.Array

Alias for field number 11

inv_dz: jax.jaxlib._jax.Array

Alias for field number 12

inv_dx_h: jax.jaxlib._jax.Array

Alias for field number 13

inv_dy_h: jax.jaxlib._jax.Array

Alias for field number 14

inv_dz_h: jax.jaxlib._jax.Array

Alias for field number 15

pad_x_lo: int

Alias for field number 16

pad_x_hi: int

Alias for field number 17

pad_y_lo: int

Alias for field number 18

pad_y_hi: int

Alias for field number 19

pad_z_lo: int

Alias for field number 20

pad_z_hi: int

Alias for field number 21

dx_arr_f64: numpy.ndarray | None

Alias for field number 22

dy_arr_f64: numpy.ndarray | None

Alias for field number 23

dz_f64: numpy.ndarray | None

Alias for field number 24

face_layers: dict[str, int] | None

Alias for field number 25

shape

Grid shape (nx, ny, nz) — duck-typing compatible with Grid.

axis_pads

Leading (lo) per-axis pad — the number coordinate-offset callers subtract from array indices to recover user coords.

def is_traced(self, axis) -> bool:

Whether axis's cell widths are a JAX tracer (decision 6).

True on the mesh-as-design-variable path, where the profile is a trace leaf and has no host copy. A traced axis still steps the solver and still differentiates; what it cannot do is answer a host-side structural question, which is why index_of refuses on it.

def cells(self, axis) -> numpy.ndarray:

PRIMAL cell widths along axis, full padded length n.

float64 on the host for a concrete axis -- the float64 spine, which is the source of truth (decision 1), NOT the float32 solver store. A traced axis returns its tracer unchanged, in jnp.

The last entry is the bounding-node duplicate (#562): it supplies the N+1-th E node and carries no physical extent, so sum(cells) overshoots the realized wall-to-wall extent by one cell. Read extents from node_of or interior_cells.

A spine whose length disagrees with the axis is REFUSED rather than returned: the spine and the float32 store are two views of one padded profile, so a length mismatch means they are not, and every node derived from it would sit at the wrong index. A MISSING spine is a different case, handled below.

A grid built by hand (the NonUniformGrid(...) constructor called directly, rather than make_nonuniform_grid) may carry no spine. It then gets the float32 store widened back to float64 AND an ExactNodeSpineMissingWarning, the same degraded line and the same warning coords_from_nonuniform_grid emits -- silently falling back is the #802 realization with no trace of why.

def duals(self, axis) -> numpy.ndarray:

DUAL (E-node) spacings along axis.

dual[0] = d[0], dual[k] = (d[k-1]+d[k])/2 -- the control volume an E node's material acts over, and the metric the NU E update divides its curl by. float64 for a concrete axis; a traced axis goes through e_node_dual_spacings so the gradient survives.

Reproduces e_node_dual_spacing_at(grid.cells(axis), k) for every k bit for bit. Against the float32 solver store the two spellings agree only to float32 roundoff -- that gap is the store's, not the rule's, and it is why the accessor reads the spine.

def index_of(self, axis, x: float) -> int:

Padded index of the node nearest physical coordinate x.

Refuses on a traced axis (decision 6, closes G9). The existing position_to_index answers there by substituting a NOMINAL uniform mesh of fallback_dx cells and returns an index for a mesh the solver is not running -- silently the wrong cell whenever the traced profile is graded. That fallback stays in position_to_index for 0a because consumers still call it; 0b retires it as each consumer moves onto this accessor.

Which node (#1295, #1342): the nearest one by float64 distance, and at a tie (two distances agreeing to 1e-9 of the local cell) the LOWER one -- nearest_node_index, the function the sheet snap itself calls, on the node line the conductors are rasterized on (_axis_node_positions of the float64 spine: the closed form (i - pad_lo) * cell on a constant axis, the cumulative sum on a graded one). A port, source or probe and a sheet declared at the same coordinate therefore land on the same node, bit for bit. position_to_index and pos_to_nu_index resolve through the same function. The uniform Grid still rounds an exact tie to the even node until #1342.

A coordinate outside the interior span is REFUSED, as on the uniform grid. _axis_position_to_index clamps instead: argmin over the edge list always returns some index, so asking for a position a metre outside a 6 mm domain silently gives the last face and the caller stamps its material there. That legacy clamp stays where it is for 0a -- consumers still call position_to_index -- and 0b retires it as each consumer moves onto this accessor.

The bound is the interior, [node_of(pad_lo), node_of(last interior node)], because that is the range the edge list can represent; a coordinate inside an absorber pad has no interior node to name. The comparison carries DECLARED_SPAN_TOL_M (1e-12 m) of slack at each end. That is not a fudge factor: it is the node tolerance the mesh builder itself claims. make_band_profile guarantees that every declared interface lands on a cumulative node coordinate to 1e-12 m and that the cells sum to the declared span to the same figure, and assert_cells_span_declared_profile holds the constructor to it. A grid that only places a node to 1e-12 m cannot tell a coordinate 1e-13 m past the face from the face, so refusing one would be refusing a distinction the grid does not make. Outside that band the refusal is exact.

That figure grows with the column: the builder documents 1e-11 m on a 10 m column of 1e5 cells. On a board or a waveguide, the sizes this lane meshes, 1e-12 m is the right order. A caller meshing metres should read the builder's guarantee before trusting the boundary case.

def node_of(self, axis, i: int):

Physical coordinate of the E node at padded index i.

Origin at the first interior node, so node_of(axis, pad_lo) == 0.0. Derived from the cells by cumulative sum, except that a CONSTANT axis takes the closed form (i - pad_lo) * d[0] -- which is what keeps a uniform-valued NU axis bit-identical to the uniform lane's own node line (#807). Both branches are _axis_node_positions, so this equals coords_from_nonuniform_grid(grid).<axis>[i] bit for bit.

A traced axis returns a traced scalar (jnp cumulative sum), the same arithmetic coords_from_nonuniform_grid runs in-trace.

Cost: this builds the whole axis to return one entry, because reusing the spine producer is what makes the answer bit-identical to the coordinate arrays. Nothing calls it in a loop in 0a. A consumer in 0b that needs many nodes asks coords_from_nonuniform_grid once, or this grows a cached array -- not a second spelling of the arithmetic.

def boundary_cell(self, axis, side: str):

The cell width at the "lo" or "hi" face of axis.

The number a CPML profile is entitled to calibrate against, per face. make_nonuniform_grid pins the first and last interior cells of x and y to the scalar dx / dy and pads each face with copies of them, so on a built grid both sides equal that scalar; z has no scalar and this is the only way to ask.

cpml.py fills its six per-face slots from this accessor.

def is_constant(self, axis) -> bool:

Whether every cell on axis has the same width.

The predicate decision 4 selects the uniform kernel on, instead of "was a profile given" (rfx/api/_mesh.py:152-156).

A TRACED axis returns False. Constancy is not decidable on the host there, and False is the safe direction: it routes the axis to the non-uniform kernel, which is correct for a constant mesh too, whereas True would hand a possibly graded mesh to a kernel that folds one scalar.

def make_nonuniform_grid( domain_xy: tuple[float, float], dz_profile: numpy.ndarray, dx: float, cpml_layers: int = 8, *, dx_profile: numpy.ndarray | None = None, dy_profile: numpy.ndarray | None = None, pec_faces: set[str] | None = None, pmc_faces: set[str] | None = None, cpml_axes: str = 'xyz', face_layers: dict[str, int] | None = None, dt: float | None = None, dt_min_cell: float | None = None, dt_caller: str | None = None) -> NonUniformGrid:

Create a non-uniform Yee grid.

Parameters

domain_xy : (Lx, Ly) in metres Only used when dx_profile / dy_profile are None — in that case the xy mesh is uniform with spacing dx. dz_profile : 1D array of z cell sizes in metres (physical domain only) dx : float Boundary cell size (also used for CPML padding and as the uniform-xy spacing when no xy profile is provided). cpml_layers : int Number of CPML cells added to each face (when that face is absorbing — see pec_faces / pmc_faces). dx_profile, dy_profile : 1D arrays or None Optional per-cell x / y spacings for the physical (non-CPML) interior. When provided, the first and last values must match dx (they set the boundary cell size used by the CPML). pec_faces, pmc_faces : set of str or None Face labels (x_lo, x_hi, y_lo, y_hi, z_lo, z_hi) where the boundary is PEC / PMC. Per-face pad count is forced to 0 on these faces so the reflector plane aligns with the user domain edge. Added 2026-04 to close the PMC+CPML composition gap on the non-uniform mesh path. cpml_axes : str Axes that participate in CPML allocation (default "xyz"). Per-face allocation still applies; an axis absent from cpml_axes gets pad=0 on both faces. dt : float or None Concrete time step (s) to use INSTEAD of the Courant step this builder derives from min(cell). None (default) keeps the derived step and is byte-identical to the historical behaviour.

Why it exists: the derived step is a function of the smallest
cell, so a mesh that is a design variable moves ``dt`` with the
deformation, and every observable read off the run then carries
the step change as well as the geometry change. Pinning one step
across a deformation family makes the sweep one antenna at one
step. The value must be CONCRETE — a traced ``dt`` would put the
step itself in the derivative.

dt_min_cell : float or None Smallest cell size (m) the caller's TRACED axes reach anywhere in the family they will run. Required with dt when any profile is a tracer, because a tracer carries no host value to measure the realized Courant limit against. Concrete axes are always measured from their realized profile; this declared number is used only for the axes that cannot be.

def make_band_profile( edges, cell_sizes, *, max_ratio: float = 1.4, protected=None, boundary_cell: float | None = None, min_cells: int = 1) -> numpy.ndarray:

Build a 1-D cell-size profile whose interfaces land on node planes and whose adjacent-cell ratios obey max_ratio everywhere.

Axis-agnostic: the same output serves dx_profile, dy_profile or dz_profile of make_nonuniform_grid().

Parameters

edges : sequence of float Sorted physical coordinates (m) of every interface that must lie on a node plane, INCLUDING the domain start and end. cell_sizes : sequence of float Target cell size (m) per segment, len(edges) - 1 entries. max_ratio : float Cap on every adjacent-cell ratio (default 1.4 — the validated z-axis multi-band cap; pass 1.3 for an in-plane profile, the in-plane threshold, see docs/guides/support_matrix.md). Must be finite and >= 1.0001 (a 2x transition at that ratio already needs 6932 cells; the plateau solve's cost grows with the ramp length — measured 0.03 s at 1.0001 on a 40-cell profile and 0.4 s for a 1 um pin descending into 10 mm of 100 um cells, an 8110-cell profile). protected : sequence of bool, optional Per segment. A protected segment is realized UNIFORM — n = max(min_cells, ceil(span / target - 1e-9)) equal cells — and never hosts a ramp cell; a free segment (default) may host the ramps its neighbours need. boundary_cell : float, optional Pins cells[0] == cells[-1] == boundary_cell exactly (the x/y CPML contract of make_nonuniform_grid()). Both end segments must be free and wide enough to hold the pinned cell plus its ramp (ValueError otherwise). min_cells : int Minimum cell count of a PROTECTED segment (default 1).

Returns

np.ndarray float64 cells. Invariants, for any valid input: every edge is a cumulative node coordinate to 1e-12 m; every adjacent ratio is <= max_ratio (to 1e-9), INCLUDING seams between two protected segments; the sum equals edges[-1] - edges[0] to 1e-12 m; with boundary_cell, both end cells equal it bit-exactly. The two 1e-12 m figures are float64 accumulation of the cells (a running sum, as the rasterizer positions nodes) and hold for columns up to about 1 m with up to 1e4 cells; the miss grows with column length and cell count (measured 1e-11 m on a 10 m column of 1e5 cells, 2.2e-9 m on 100 m / 1e6 cells), while each cell itself matches its declared value to 1e-15 relative.

Raises

ValueError On inconsistent input: edges not strictly increasing or two edges closer than 1e-12 m (the node tolerance — merge them), a non-positive cell size, max_ratio not finite or below 1.0001, a protected end segment with boundary_cell, an end segment shorter than the pinned cell or too short to hold the pin plus its ramp, and a profile that would exceed 1,000,000 cells (a sliver segment pulls its neighbours down to its size under the ratio law: a 1e-9 m protected sliver between 1 mm blocks asks for 1.4e6 cells).

Notes

Ramps are geometric (per-step ratio spread evenly, <= max_ratio) and placed INSIDE the coarser free segment on whichever side needs one; the free run's plateau value is solved so the run sums to its declared span exactly (no cell above its target, no rescale). Two adjacent PROTECTED segments whose seam exceeds the cap cannot be served by a ramp: the coarser one's uniform cell is reduced (more cells) until the seam complies, iterated to a fixed point. The cost is cells AND, whenever the refined block was the coarsest declared cell, a smaller minimum cell than declared — the time step follows the global minimum, so dt drops with it. Measured on 3000 seeded stacks (rng 1; 2-6 segments, spans 20 um-3 mm, targets 0.05-2x the span, 70 % protected, caps 1.2-1.4): the realized minimum sits below the declared minimum in 410 of them, down to 0.31x in the worst case (two single-cell protected blocks whose span ratio just exceeds the cap at 1.2). Hand case: [0, 0.1, 0.1355] mm, [25, 100] um, both protected, 1.4 realizes 5 x 20 um + 2 x 17.75 um, minimum 0.71x the declared 25 um. A free segment too thin for any ramp is a refinement source for its neighbours in the same way.

class Simulation(rfx.api._mesh._MeshMixin, rfx.api._preflight._PreflightMixin, rfx.api._sparams._SparamMixin, rfx.api._compile._CompileMixin, rfx.api._execute._ExecuteMixin, rfx.api._artifacts._ArtifactsMixin, rfx.api._ad_memory._AdMemoryMixin):

Declarative FDTD simulation builder.

Parameters

freq_max : float Maximum simulation frequency (Hz). domain : (Lx, Ly, Lz) in metres Physical domain size. For 2D modes Lz is ignored. boundary : "pec", "cpml", or "upml" Boundary condition. Default "cpml". cpml_layers : int Number of CPML layers per face. Default 16 (ignored for "pec"). dx : float or None Cell size override (metres). Auto-computed if None. mode : str "3d" (default), "2d_tmz" (Ez, Hx, Hy), or "2d_tez" (Hz, Ex, Ey). snap : {"strict", "declared"} Strict (default) refuses PEC sheets solved more than 1% off their drawn in-plane size. "declared" accepts the difference as a warning and records the choice in realized geometry; no solved numbers change. dt : float or None Concrete time step (s) for the NON-UNIFORM lane, used instead of the Courant step derived from the smallest cell. None (the default) keeps the derived step and is byte-identical to before; passing it without any of dz_profile / dx_profile / dy_profile is refused, because the uniform lane derives its own step in Grid.courant_dt.

Why it exists: the derived step is a function of ``min(cell)``, so a
mesh that is a design variable moves ``dt`` as it deforms and every
observable read off the run carries the step change alongside the
geometry change. One pinned step makes a deformation sweep one board
at one step. A step above the Courant limit of the REALIZED cells is
refused, never clipped.

dt_min_cell : float or None Smallest cell size (m) the caller's TRACED axes reach anywhere in the family they will run. Required with dt when a profile is a JAX tracer, because a tracer carries no host cell size for the limit to be measured against at build time. Concrete axes are always measured from their realized profile; a traced axis is measured against this declared floor when the run builds its mesh, and the run is refused if the realized cells fall below it. precision : str "float32" (default), "mixed", or "float64". When "mixed", field arrays (E, H) use float16 for ~2x memory reduction while material coefficients and DFT accumulators stay float32. When "float64", field arrays use float64 storage AND the Yee update arithmetic runs at float64 (issue #630 — prior releases hard-pinned the Yee curl/update arithmetic to float32 regardless of storage dtype, so float64 fields were silently re-quantized to float32 every timestep; the arithmetic dtype is now jnp.promote_types(field_dtype, jnp.float32), so "float32"/"mixed" are numerically unaffected and byte-identical to before). "float64" requires JAX's x64 mode to already be enabled by the caller (jax.config.update( "jax_enable_x64", True) or jax.experimental.enable_x64()) — without it JAX silently downcasts float64 arrays back to float32, and preflight() warns if this mismatch is detected. Material coefficients (eps_r/sigma/mu_r) and precomputed CPML profile arrays stay float32 either way (fixed setup-time constants measurably bias the primal but do not create the per-timestep rounding-lattice noise floor that the compute-dtype pin did — see issue #630's f64-lift measurement). "mixed"/"float64" are UNIFORM-SINGLE-DEVICE-LANE ONLY today: field_dtype is threaded only by rfx/runners/uniform.py — the non-uniform-mesh, distributed (devices=/distributed=True), and subgridded (refinement) runners do not thread it, so a non-float32 precision there would silently run float32 fields. run() / forward() raise NotImplementedError rather than do that silently; preflight() also warns in advance for the non-uniform-mesh case (the distributed case is a call-time run()/forward() kwarg, invisible to preflight, so its NotImplementedError at dispatch is the only enforcement point).

ACCURACY CAVEAT for ``"mixed"`` with a CPML boundary (issue #644):
float16 field storage is fine for transient and field-shape work
(measured 0.20% relative L2 error on Ez vs ``"float32"`` at 50
steps), but it raises the absorber's residual floor. On the
fixture in ``tests/unit/grid/test_mixed_precision.py`` — a 40**3 domain,
dx = 3.0 mm, point source, 400 steps — total field energy settles
to about -76 dB below peak under ``"float32"`` but only about
-59.5 dB under ``"mixed"``: roughly 17 dB worse, consistent with
float16's ~1e-3 machine epsilon quantizing the stored fields.
Those two numbers are from that one fixture, not a universal
bound; the floor scales with float16 epsilon, so expect the same
order wherever the field storage is float16. Practical rule:
``"mixed"`` is not a drop-in ``"float32"`` substitute when the
quantity of interest is a reflection coefficient, an S-parameter,
or anything else living near the absorber floor — use
``"float32"`` (the default) for those.

solver : str "yee" (default) for the standard explicit scheme or "adi" for the experimental ADI-FDTD path. The homogeneous, lossless split with compatible domain boundaries removes the explicit CFL stability restriction. Interior PEC (sheets, wires, volumes) is refused at every factor on both lanes: its projection does not inherit that guarantee. Cavity accuracy without interior PEC is tested in 2D TMz (mode="2d_tmz", 2% resonance gate at 5x CFL, test_adi_cavity_resonance) and 3D (full Zheng–Chen–Zhang two-sub-step scheme since 2026-07-13, issue #338: 2% PEC-cavity eigenfrequency gate at 2x CFL, ~15 cells/wavelength). 3D dispersion error grows ~dt^2, so preflight emits an adi_3d_accuracy envelope advisory when adi_cfl_factor > 2 on a 3D grid — large factors trade wavelength-scale accuracy for stiff-mesh throughput. adi_cfl_factor : float Timestep multiplier relative to the grid's Yee CFL timestep when solver="adi". Default 2.0; for quantitative wavelength-scale 3D results prefer <= 2.0 (see solver). stencil_order : int Spatial finite-difference order for the explicit Yee update: 2 (default, the standard (2,2) scheme — BYTE-IDENTICAL to prior releases) or 4 (the (2,4) fourth-order-in-space stencil). Order 4 is supported ONLY on the plain uniform Cartesian vacuum/dielectric path: boundary pec/periodic (NOT cpml/upml), default yee solver (NOT adi), uniform mesh (no dz/dx/dy profile), no dispersive materials, no anisotropic/subpixel eps, no conformal PEC, no Kerr, not distributed. Any unsupported combination with stencil_order=4 raises NotImplementedError. Order 4 derates the timestep by ~0.857x for stability (the (2,4) CFL bound).

Simulation( freq_max: float, domain: tuple[float, float, float], *, boundary: str | rfx.boundaries.spec.BoundarySpec | dict = 'cpml', cpml_layers: int = 16, cpml_kappa_max: float = 1.0, dx: float | None = None, mode: str = '3d', dz_profile: numpy.ndarray | None = None, dx_profile: numpy.ndarray | None = None, dy_profile: numpy.ndarray | None = None, dt: float | None = None, dt_min_cell: float | None = None, precision: str = 'float32', solver: str = 'yee', adi_cfl_factor: float = 2.0, stencil_order: int = 2, interface_eps: str = 'sampled', snap: str = 'strict', **_removed_kwargs)
def add_refinement( self, z_range: tuple[float, float], *, ratio: int = 4, xy_margin: float | None = None, tau: float = 0.5, validation: str = 'production', topology: str = 'overlap_z_slab') -> Simulation:

Add a z-axis refinement region for SBP-SAT subgridding.

Refinement acts only through run() on a uniform mesh. Other solve entry points, including forward() and optimization, refuse it.

The experimental runner covers the specified z-range across the full x/y interior at dx_fine = dx_coarse / ratio. xy_margin enables an experimental research-only local x/y window whose fine region is inset from the physical x/y boundaries by that distance.

Parameters

z_range : (z_lo, z_hi) in metres Physical z-range for the fine region. ratio : int Refinement ratio (fine cells per coarse cell). Default 4. xy_margin : float or None Experimental x/y inset in metres. None keeps the full x/y interior. A finite non-negative value creates a local window spanning [xy_margin, Lx - xy_margin] and [xy_margin, Ly - xy_margin]. Production validation still rejects this lane until waveform gates and crossval pass. tau : float SAT penalty coefficient (default 0.5). Higher values give stronger coupling but more dissipation. validation : {"production", "research", "off"} Production refuses the unstable, unverified subgridded lane before stepping (#1465). An empty PEC cavity's first resonance is 8 % high and its field grows. Use dx/dy/dz profiles for local resolution. Explicit "research" or "off" runs with a warning; neither mode establishes physics support. topology : {"overlap_z_slab", "stage2_disjoint_3d"} Internal topology selector. "overlap_z_slab" is the current public runner. "stage2_disjoint_3d" records the selected centered/two-interface integration lane but remains a research-only contract until its public runner wiring and waveform gates pass.

def validate_subgrid(self, *, mode: str | None = None):

Return the production-envelope validation report for subgridding.

Every mode reports the subgridded lane as unsupported because it is unstable and unverified (#1465). Experimental envelope checks remain available for research diagnostics; opting in does not confer support.

def add_material( self, name: str, *, eps_r: float = 1.0, sigma: float = 0.0, mu_r: float = 1.0, debye_poles: list[DebyePole] | None = None, lorentz_poles: list[LorentzPole] | None = None, chi3: float = 0.0) -> Simulation:

Register a named material.

Parameters

chi3 : float Third-order (Kerr) susceptibility in m^2/V^2. When non-zero, an ADE correction is applied after each E-field update inside the scan body.

def add( self, shape: rfx.geometry.csg.Shape, *, material: str) -> Simulation:

Add a geometric shape filled with a named material.

Parameters

shape : Shape material : str Registered or library material name.

Notes

A PEC shape is a VOLUME under the lattice ownership contract (#931): its primal cells are sampled at cell centres and every E edge incident to an occupied cell is shorted, so a Box drawn z_a -> z_b on node planes realizes walls at BOTH planes with realized thickness = drawn thickness. A PEC Box with exactly one zero-extent axis (lo == hi) is a SHEET declaration, realized exactly as add_thin_conductor() would realize it. A PEC Box thinner than one local cell (but not zero) is refused at assembly; so is any PEC shape that rasterizes to zero cells. There is no per-entry realization knob.

def realized_geometry(self):

Return the immutable host record of this configuration's built geometry.

Build only: entities, solved sheet spans, signed face residuals in metres, domain padding, and driven port edges. Dense diagnostic arrays are built on request here. run() attaches a compact record built independently from its own assembly as Result.realized_geometry.

def fidelity_report(self, print_report: bool = True):

Input-fidelity audit: declared vs solved, per entity, in input units — run BEFORE any solve. See rfx.fidelity.fidelity_report() (design rule: never predicts result-side impact; rasterization, realization class and materialization only).

def add_source( self, position: tuple[float, float, float], component: str = 'ez', *, waveform=None, amplitude_kind: str | None = None) -> Simulation:

Add a soft point source (no impedance loading).

Unlike add_port(), this does NOT add a resistive load. Ideal for resonance characterization where port loading would damp the cavity response.

Uses a differentiated GaussianPulse by default (near-zero DC content, ~1e-4). This prevents static charge accumulation on PEC surfaces.

Parameters

position : (x, y, z) in metres component : "ex", "ey", or "ez" waveform : excitation pulse (default: GaussianPulse(f0=freq_max/2, bandwidth=0.8)) amplitude_kind : 'field' | 'current' | None What the waveform amplitude MEANS — issue #571. Boundary- and mesh-independent once explicit:

- ``'current'``: the amplitude is a current moment I(t) in A·m,
  realized as ``E += Cb * I / dV`` on every path and boundary
  (Yee ``Cb = (dt/eps)/(1 + sigma*dt/(2*eps))``; ADI refuses
  ``'current'`` and requires explicit ``'field'``,
  ``dV`` = local cell volume). Resolution-independent injected
  power; Meep's convention; the declaration default.
- ``'field'``: the amplitude is a raw E-field increment per
  step, ``E += w(t)`` on every path and boundary.

Cell-volume convention: ``dV = dx * dy * dz`` at the source
cell; a 2D grid is treated as ONE CELL DEEP (``dz`` one cell,
duck-typed to ``dx``), so ``dV = dx**3`` on a cubic 2D grid,
not the per-unit-length ``dx**2``
(``rfx/api/_source_semantics.py``). On a non-uniform mesh
``dV`` is the E node's control volume — the primal per-cell
width on the component's own axis and the DUAL spacing
``(d[k-1]+d[k])/2`` on the two transverse axes (issue #672);
the two coincide on a uniform profile.

``None`` means ``'current'`` (E += Cb*I/dV, I is a current moment in A·m)
on every path and emits one
`DeprecationWarning` per Simulation. Pass the kind
explicitly to silence it.

The open-boundary + ``subpixel_smoothing`` cross-path residual
(0.18 % amplitude, issue #582) is a solver defect independent
of this parameter.
def add_polarized_source( self, position: tuple[float, float, float], *, polarization: str | tuple = 'ez', waveform=None, amplitude_kind: str | None = None) -> Simulation:

Add a polarized point source.

Parameters

position : (x, y, z) in metres polarization : str or tuple - "ez", "ex", "ey" — linear single-component - "circular" or "rhcp" — accepted complex-component shortcut; time-domain quadrature is not independently verified - "lhcp" — accepted complex-component shortcut; same limitation - (Ex, Ey) tuple — normalized component weights; real tuples are the documented linear-polarization scope - "slant45" — 45° linear (Ex = Ey) waveform : excitation pulse (default: GaussianPulse) amplitude_kind : 'field' | 'current' | None Threaded through to every internal add_source() call — see add_source for the semantics. None resolves to 'current' and warns once per simulation; pass the kind explicitly to silence the warning.

def add_coaxial_port( self, position: tuple[float, float, float], face: str = 'top', *, pin_length: float = 0.005, pin_radius: float = 0.000635, outer_radius: float = 0.002055, impedance: float = 50.0, waveform=None, terminates=None) -> Simulation:

Add an SMA-style coaxial probe port.

Parameters

position : (x, y, z) — center of port on cavity wall (metres) face : "top", "bottom", "front", "back", "left", "right" pin_length : float — pin protrusion into cavity (default 5mm) pin_radius : float — center pin radius (default 0.635mm, SMA) outer_radius : float — outer conductor radius (default 2.055mm) impedance : float — port impedance (default 50 ohm) waveform : excitation pulse (default GaussianPulse at freq_max/2)

def add_thin_conductor( self, shape: rfx.geometry.csg.Shape, *, sigma_bulk: float = 58000000.0, thickness: float = 3.5e-05, eps_r: float = 1.0, surface_impedance_f0: float | None = None) -> Simulation:

Add a thin conductor with subcell correction.

Parameters

shape : Shape Geometric region of the conductor. sigma_bulk : float Bulk conductivity (S/m). Default: copper (5.8e7).

**This single value decides the model.** At or above 1e6 S/m the
shape becomes a PEC sheet and ``thickness`` and ``eps_r`` are
never read (`rfx.materials.thin_conductor.apply_thin_conductor()`
returns the material arrays untouched and only ORs the mask). Below
1e6 S/m the shape becomes a lossy sheet whose conductivity is
``sigma_bulk * thickness / d_norm``, where ``thickness`` IS
load-bearing and ``d_norm`` is the local sheet-normal spacing
(see ``surface_impedance_f0`` below for the graded-mesh case).
Every real metal — copper 5.8e7, aluminium 3.5e7, even stainless
steel 1.4e6 — is on the PEC side, so by default
(``surface_impedance_f0`` unset) for metals this call models a
lossless perfect sheet and nothing else. Pass
``surface_impedance_f0`` to opt in to band-centre Leontovich
conductor loss instead (see below).

thickness : float Physical thickness (metres). Default: 35 µm (1 oz copper). Only used on the lossy path (sigma_bulk < 1e6); ignored entirely for metals — see sigma_bulk above. eps_r : float Relative permittivity (default 1.0). Lossy path only, same as thickness. surface_impedance_f0 : float or None Opt-in band-centre Leontovich surface-impedance loss (issue #669). Default None = exact legacy behaviour on every path. When set to a positive frequency (Hz), the sheet — for ANY sigma_bulk > 0, metals included — is modelled as a resistive sheet of sheet resistance Rs0 = sqrt(pi*f0*mu0/sigma_bulk) (the thick-conductor surface resistance at f0), realized NODE-THIN (#677): a per-step operator applies an exact exponential-stepping resistive update E = A*E + B*curlH with sheet conductivity sigma_sheet = (1/Rs0)/d_norm on exactly the tangential E edges a PEC sheet on the same cells would zero — so toggling surface_impedance_f0 on a metal changes LOSS, never the conductor's surface position (before #677 the sheet folded into materials.sigma as a full-cell slab, moving resonances by geometry). d_norm is the local spacing normal to the sheet: the cell size on a uniform grid, and on a graded one the E-node DUAL spacing (d[k-1]+d[k])/2 (#671). Both the uniform and the non-uniform (dz_profile) run()/S-parameter lanes apply the operator; lanes that do not (distributed, subgridded, ADI, UPML, dispersive-overlap, multimode-waveguide, MSL-junction/mixed, optimize/topology drivers) refuse f0 sheets loudly rather than silently dropping them; compute_msl_s_matrix applies the sheet through its run()/forward() device dispatches (#679). The sheet contributes NO PEC cells and no materials.sigma/eps_r entries.

**Consequence for material inspection (#695): an f0 sheet is
INVISIBLE to the assembled arrays.** It is absent from
``pec_mask`` and it adds nothing to ``materials.sigma``, so a
conductor test written the obvious way --
``pec_mask | (sigma > 1e3)`` -- finds no metal at all on a
board whose traces are all f0 sheets, and a connectivity
check built on it reports a perfectly healthy model as
disconnected. Use `Simulation.conductor_mask()`, which
returns ``pec_mask | (sigma > threshold) | union(f0 sheet
cell masks)`` -- the whole conductor footprint in one
spelling, on the grid the run will actually use. The runtime
edge masks (``mask_ex/ey/ez``) live on the
``SheetImpedanceCtx`` the runners build; the accessor returns
the CELL footprint, which is what an occupancy or
connectivity check wants.

Model scope (be precise about what you get):

- **Magnitude loss only.** The reactive part ``Xs = Rs``
  (internal inductance) is NOT modelled, so resonances read
  slightly high in frequency.
- **Frequency-flat within a run**: ``Rs`` is frozen at ``f0``
  with relative band error ``|sqrt(f/f0) - 1|`` (about +-11%
  across an 8-12 GHz band centred at 10 GHz).
- **Thick-conductor model**: valid for ``thickness >= ~3``
  skin depths at ``f0``. For thin films the DC path (omit this
  kwarg, ``sigma_bulk < 1e6``) is the correct model;
  ``thickness`` deliberately does NOT enter ``sigma_eff`` in f0
  mode (Leontovich loss is thickness-independent).
- **Transmission leakage** ``(2*Rs/eta0)**2`` makes the sheet
  unusable for shielding-effectiveness claims beyond ~60 dB.
  (Validated against the closed form ``T = 2Rs/(2Rs+eta0)``
  to within a few percent, frequency-flat —
  tests/oracle/test_leontovich_alpha_oracle.py.)
- The operator touches TANGENTIAL sheet edges only; the
  sheet-normal E component is never loaded (#677 removed the
  pre-existing isotropic-fold caveat).
- ``f0`` and ``sigma_bulk`` are differentiable DoFs in f0 mode
  (closed forms ``d(sigma_sheet)/df0 = -sigma_sheet/(2*f0)``,
  ``d(sigma_sheet)/d(sigma_bulk) =
  +sigma_sheet/(2*sigma_bulk)``). Geometry (the rasterized
  mask) stays non-differentiable. Traced values skip the
  concrete-value validation below.

Sheet shapes (issue #674): ANY shape implementing
``mask_on_coords`` and ``bounding_box`` may carry a
surface-impedance sheet — a patterned ground plane with
clearance holes, a meandered arm, an imported CAD outline
(`~rfx.geometry.mesh_import.MeshShape`), a disc, a
strip. The realization is per occupied cell, so a hole is
simply a cell the operator never touches. What is NOT supported
is a body with HEIGHT: the sheet must rasterize to exactly ONE
cell layer along its normal, because ``sigma_sheet`` is the
conductance of a single E node. A 3-D solid, a bent/L-shaped sheet, or a slab
thicker than a cell raises ``ValueError`` when the grid is
built — folding such a body per cell would multiply the sheet
conductance by the layer count while still reporting ``Rs0``.
A sheet that rasterizes to ZERO cells raises too: a sub-cell
mesh slab registers only where a grid node falls inside it, and
silently vaporized metal is the #369 class. (A ``Box`` drawn
with equal lo/hi on its normal axis snaps to the nearest node by
construction, so it always satisfies both.)

Validation at add time (concrete values only): ``f0 <= 0`` and
``sigma_bulk <= 0`` with ``f0`` set each raise ``ValueError``,
as does a shape with no ``mask_on_coords`` or no bounding box.
The rasterized one-layer and non-empty checks need the grid, so
they raise at build time, on BOTH lanes (the legacy DC path
also refuses non-Box shapes on a non-uniform mesh).

Notes

A PEC sheet occupying ONE cell is modelled as a surface, not as a conductor of that cell's thickness: rfx.boundaries.pec.apply_pec_mask() zeroes a tangential E component only where the mask has a neighbour along that component's axis, so the normal component survives as surface charge. Stamping the same conductor TWO cells thick therefore changes the model rather than the thickness. Measured on an MSL thru at dx = 84.67 µm: one cell gives Re(Z0) = 44.1 Ω and two cells 41.9 Ω. Reproduce with scripts/diagnostics/thin_conductor_cell_thickness_probe.py.

Note this pair no longer proves the model change on its own. Until issue #511 was fixed the one-cell reading was 38.8 Ω, so the thicker stamp appeared to read the HIGHER impedance — backwards for a fringing-capacitance effect, which is how the model change was first argued. That inversion was an extractor artifact: the modal voltage summed one Ez edge too many, and a two-cell stamp happens to mask that edge, so only the one-cell number was biased. With both corrected the ordering is the ordinary one (thicker → lower Z0) and the 2.25 Ω step is consistent with either a model change or a genuine thickness effect. The model change is still real — it follows from apply_pec_mask directly — but this measurement no longer discriminates between the two.

Conductor LOSS is therefore unavailable for metals through this API. On the lanes this repo gates that is a small omission — copper loss on a 10 mm 50 Ω microstrip is 0.04-0.05 dB at 3-4.5 GHz against a 0.45 dB gate budget, and the metal is 26-36 skin depths thick so its geometry is electromagnetically a half-space — but it is a real limit for anything whose loss budget IS the conductor, e.g. resonator Q.

def add_pinned_sheet( self, *, plane_index: int, i_range: tuple[int, int], j_range: tuple[int, int], normal_axis: int = 2, sigma_bulk: float = 58000000.0, thickness: float = 3.5e-05, eps_r: float = 1.0, surface_impedance_f0: float | None = None, name: str | None = None) -> Simulation:

Declare a thin PEC conductor by NODE INDICES instead of metres.

add_thin_conductor draws metal in metres, and the grid then decides which node lines that reaches. This draws it on the node lines directly: the conductor occupies node plane_index along normal_axis and the inclusive node ranges i_range / j_range on the other two axes, in increasing axis order — for the default normal_axis=2 that is i_range on x and j_range on y.

Indices are INTERIOR (unpadded) node indices: interior node i sits at i * dx from the domain origin on a uniform mesh, and is the i-th interior node on a graded one. Absorber padding is added when the sheet is realized.

Why it exists: the conductor's PHYSICAL size is then whatever the cells between those nodes add up to, so stretching the cells makes the metal longer without moving it off the lattice and without any sub-cell metal. That is what lets jax.grad reach a patch edge through dx_profile / dy_profile / dz_profile — a metric sheet on a mesh that is a JAX tracer is still refused, because its corners cannot be read off a traced node line.

The declaration is mesh-independent by construction, so the same call gives the same footprint on the uniform and non-uniform lanes, traced or not. A range spanning a single node line is refused: one node line carries no E edge, so the metal would carry no current.

Only the lossless PEC sheet is supported (sigma_bulk >= 1e6, surface_impedance_f0 unset); thickness and eps_r are accepted for signature compatibility with add_thin_conductor() and are not read, exactly as they are not read there for a metal.

def add_port( self, position: tuple[float, float, float], component: str = 'ez', *, impedance: float = 50.0, waveform: GaussianPulse | None = None, extent: float | None = None, radius: float | None = None, excite: bool = True, direction: str | None = None, reference_plane_cells: int | None = None, terminates=None) -> Simulation:

Add a lumped port (single-cell) or wire port (multi-cell).

Parameters

position : (x, y, z) in metres component : "ex", "ey", or "ez" impedance : port impedance in ohms (default 50) waveform : excitation pulse (default: GaussianPulse at freq_max/2). Ignored when excite=False. extent : float or None When provided, the port spans from position along the port axis by this distance (metres), creating a multi-cell WirePort. For example component="ez", extent=0.0015 spans the port from z to z + 0.0015. radius : float or None Opt-in thin-probe radius in metres (requires extent). None preserves the legacy filament, whose effective radius is approximately 0.20 times the transverse cell size. The local self-field model requires a square, locally uniform transverse mesh, uniform spacing along the pin, and radius <= 0.2 times the transverse spacing. Supported on single-device, nondispersive 3-D second-order Yee run/forward; unsupported solvers and material updates raise before stepping. excite : bool (default True) When True the port has BOTH a resistive termination AND a time-domain source (legacy behaviour). When False the port is a passive matched load only — no source, just the σ=1/(Z0·A) resistive termination. Passive ports are required for multi-port S-parameter extraction: excite one port at a time and probe V/I at the others to fill off-diagonal entries of the S matrix. direction : {"+x", "-x", "+y", "-y"} or None Outward-normal direction of the port (from the port cell into the external world). Used by the S-matrix post- processing to orient the V/I → (incoming, outgoing) wave decomposition. When None, the runner auto-detects from the port's position (closest boundary face). reference_plane_cells : int or None Opt-in reference-plane port waves for the wire-port S-matrix OFF-diagonal extraction (issue #313). When set to an integer N >= 1, run(compute_s_params=True) registers TWO line V/I reference planes for this port at N and 2N cells outboard (into the DUT along the line axis, i.e. opposite direction): gap-voltage line integrals at integer Yee planes plus dual adjacent Ampere loops around the PEC signal trace, accumulated in the production scan. Off-diagonal S[i, j] entries whose ports BOTH opt in are then computed from the plane waves — forward/backward split with the MEASURED two-plane line impedance Zc^2 = (V1^2 - V2^2)/(I1^2 - I2^2) (never the nominal port impedance) and phase-only de-embedding to the port plane with the MEASURED per-bin beta from the same two planes. The Phase-0 closed-box flux referee showed the port-cell wave pair does not conserve power (the port plane is near-field dominated) while the plane waves close the power budget at all bins; the plane path removes the drive-side |S21| deflation kappa(f) = 1.49-1.86 of issue #313. None (default) keeps the shipped port-cell behaviour, byte-identical. The DIAGONAL S_jj always stays on the byte-frozen legacy path either way, so forward() / 1-port S11 results are unaffected. Requires a wire port (extent=) with an explicit direction transverse to the port component, a PEC signal trace uniform across both planes, and the uniform-mesh run() lane: the non-uniform and subgridded lanes raise NotImplementedError with the opt-in set, and the distributed lane does not support compute_s_params at all (it warns that the kwarg is unsupported and ignores it).

Choosing N: place BOTH planes (N and 2N cells) roughly >= 10
cells from every port so the two-plane Zc/beta measurement
sits outside the port near-fields — the Phase-0
pre-registration rule. Measured on the canonical 16 mm thru
(dx = 0.5 mm, gap-trimmed V, 2026-07-10 battery): N=3 planes
read beta/(w/c) = 1.16-1.20 and Zc = 52-53 ohm with Im/Re up
to 8.2% (near-field contaminated; |S21| closed-box-referee
residual -3.1% at 7 GHz, row energy |S11|^2+|S21|^2 up to
1.019), while N=10 planes read the clean mid-line
beta/(w/c) = 1.046-1.059 and Zc = 47.9-48.6 ohm (Im/Re <=
1.2%) and closed the closed-box referee within 0.9% at all
bins.
def add_msl_port( self, position: tuple[float, float, float], *, width: float, height: float, direction: str = '+x', impedance: float = 50.0, waveform: GaussianPulse | None = None, excite: bool = True, n_probe_offset: int | None = None, n_probe_spacing: int | None = None, n_probes: int = 5, name: str | None = None, mode: str = 'laplace', eps_r_sub: float | None = None, terminates=None) -> Simulation:

Add a microstrip-line (MSL) port spanning the full trace cross-section.

Unlike add_port() with extent=... (a one-cell-transverse wire port), this port covers the full width × height cross-section under the trace and uses 3-probe numerical de-embedding to extract β and Z0 empirically downstream of the feed plane.

Parameters

position : (x, y, z_lo) Physical feed-plane point. The coordinate on the PROPAGATION axis (named by direction) is the feed plane; the coordinate on the other in-board axis is the trace centre; z is the substrate bottom. So a "+x" port reads it as (x_feed, y_centre, z_lo) and a "+y" port as (x_centre, y_feed, z_lo). width : float Trace width in metres, spanning the in-board axis that is NOT the propagation axis (y for an x-directed port, x for a y-directed one). height : float Substrate thickness in metres — always the z extent, because the substrate normal is fixed to z (see direction). direction : str One of "+x", "-x", "+y", "-y" — the direction the launched wave propagates. The board lies in the xy-plane and the substrate normal is always z, so the feed may run along either in-board axis (issue #661: a CAD-imported board does not get to choose its orientation).

``"+z"`` / ``"-z"`` are REJECTED with a clear error rather
than supported: a z-propagating microstrip needs its
substrate normal along x or y, which ``position`` + scalar
``height`` cannot express, and which the Laplace mode solve,
the ``"ez"`` source component, the modal voltage
``V = Σ Ez·dz`` and the trace-conductor PEC scan all assume.
Accepting it would return a z-normal answer for a board that
is not oriented that way.

``mode="eigenmode"`` remains ``"+x"``/``"-x"`` only.

impedance : float Target Z0 in ohms (default 50). Used for the σ distribution. waveform : GaussianPulse or callable, optional Excitation waveform. Defaults to a band-limited Gaussian centred at freq_max/2. Ignored when excite=False. excite : bool True → resistive termination + active source; False → passive matched termination only. n_probe_offset : int, optional Distance (cells) from the feed plane to the first probe plane. Automatic offset and spacing counts are recomputed at driver time in the cells under the port, including graded runways, uniform dy_profile different from dx, and auto meshes. A ladder crossing a grading ramp retains counts based on scalar dx. When None, bound to the LARGER of two near-field clearances: (a) the wavelength reactive far-field (issue #80 Fix B), round(0.5 * lam_min_eff / (2*pi) / dx) with lam_min_eff = c / freq_max / sqrt(eps_r_sub_estimate); and (b) the source FRINGING transient round(5 * h_sub / dx), which decays over a few substrate thicknesses (h_sub = the port height), NOT over λ. For a thin high-εr substrate (b) dominates: clearing only (a) leaves probe 0 inside the fringing transient and corrupts the V·I-split S11 of a high-Q resonant load (issue #80 patch: |S11|=8.94/1.11 → passive ~0.99 once cleared). These two terms are only the UPSTREAM (lower) edge: at compute_msl_s_matrix time the auto default additionally solves the DOWNSTREAM constraint (issue #469) — the deepest probe must stay ≥ λ_g/4 (at freq_max) clear of the nearest reflector — picking the midpoint of the compliant interval when a reflector bounds it, keeping the upstream edge when none does, and warning loudly when the interval is empty (the feed line is too short for a clean N-probe measurement). Pass an explicit value to override (explicit values are never adjusted); < 5*h_sub/dx triggers a preflight near-field warning. n_probe_spacing : int, optional Distance (cells) between consecutive probe planes. Automatic counts use the same driver-time runway recount as the offset. When None, bound so the total N-probe array span stays ~lam_min_eff/8 independent of n_probes: round(lam_min_eff / 8 / (n_probes - 1) / dx). For n_probes=3 this is exactly lam_min_eff/16 (issue #80 Fix B). Shrinking the adjacent spacing as n_probes grows keeps the probe array from running off short lines.

An AUTO spacing is additionally WIDENED at driver time
(issue #681): ``compute_msl_s_matrix`` /
``compute_mixed_s_matrix`` re-solve it toward a total span of
``(n_probes−1)·λ_g(f_max)/4`` (λ_g from the HJ ε_eff), capped
by the downstream-reflector interval and the absorbing
boundary — a ~0.1·λ_g span leaves the β fit noise-fragile
(measured: median β error 5.5% at 0.10 λ_g vs 0.8% at
0.30 λ_g under 1% probe noise). An explicit value is never
touched.

n_probes : int Number of equally-spaced voltage probe planes registered for the N-probe least-squares wave-decomposition extractor (issue #80 Fix C). Probe n sits at offset + n*spacing cells from the feed plane. N >= 3 is required; the default 5 over-determines the 2-unknown (alpha, gamma) fit, which removes the 3-probe quadratic's q→1 singularity. The current probe at probe 0 is always recorded for the absolute Z0. name : str, optional Port label used in result dicts. Auto-generated when omitted. eps_r_sub : float, optional Substrate relative permittivity. Used both for the eigenmode source and as eps_r_sub_estimate for the wavelength-bound probe-placement defaults (issue #80 Fix B). When None the estimate is resolved from the dielectric geometry already registered under the port (a shape whose bounding box contains the substrate mid-point and whose material has eps_r > 1); if no such dielectric is found it falls back to 1.0, which is conservative (largest lam_min_eff → largest offset).

def add_lumped_rlc( self, position: tuple[float, float, float], component: str = 'ez', *, R: float = 0.0, L: float = 0.0, C: float = 0.0, topology: str = 'series') -> Simulation:

Add a lumped RLC element at a single cell.

The element is modelled via Auxiliary Differential Equations (ADE) that are updated each timestep alongside the standard Yee update. Any combination of R, L, C is valid (set unused components to 0).

Parameters

position : (x, y, z) in metres component : "ex", "ey", or "ez" R : float Resistance in ohms (default 0). L : float Inductance in henries (default 0). C : float Capacitance in farads (default 0). topology : "series" or "parallel"

Notes

For series topology with a single component (e.g., pure L with R=0 and C=0), the element is handled via material folding, not the full series ADE current tracker. To ensure the series ADE path is used, specify at least two non-zero components.

def add_tfsf_source( self, *, f0: float | None = None, bandwidth: float = 0.5, amplitude: float = 1.0, margin: int = 3, polarization: str = 'ez', direction: str = '+x', angle_deg: float = 0.0, waveform: object = 'differentiated_gaussian', method: str = 'bloch', closed_box: bool = False) -> Simulation:

Add a normal-incidence plane-wave TFSF source.

Current scope is intentionally narrow: x-directed propagation, ez/ey polarization, 3D mode, and CPML boundaries. For oblique incidence, only the single transverse-axis plane implied by the chosen polarization is supported.

Parameters

closed_box : bool Use a finite six-face total-field box with CPML on all three axes. Normal incidence, uniform 3D Yee only. The default keeps the historical x slab with periodic y/z. Read the realized inclusive node bounds with tfsf_box_indices(). waveform : {"differentiated_gaussian", "modulated_gaussian", "continuous_wave"} Pulse shape injected into the 1D auxiliary grid. (continuous_wave is only supported at normal incidence — angle_deg == 0.)

``differentiated_gaussian`` (default, legacy rfx): baseband
pulse with no carrier, DC-free, spectrum peaks near
``f0·bandwidth``. Good for broadband measurements but
spectrum extends well below ``f0``.

``continuous_wave``: a single-frequency sinusoid at ``f0`` with a
raised-cosine turn-on (~8 periods), then constant amplitude. Use for
steady-state / narrowband measurements that need a well-defined
time-average ``⟨E²⟩ = A²/2`` (e.g. the quantitative Kerr SPM oracle,
#446). Not for broadband S-parameters — it excites only ``f0``.

``modulated_gaussian``: carrier-modulated Gaussian matching
Meep's ``GaussianSource(frequency=f0, fwidth=f0·bandwidth)``.
Spectrum is a Gaussian centered at ``f0`` with 1/e
half-width ``f0·bandwidth``. Use this for matched rfx-vs-Meep
crossval comparisons.

A ``CustomWaveform`` may supply a fixed, JAX-compatible real
scalar function of time, multiplied by ``amplitude``. It is
added to the auxiliary E node at ``t=n*dt``; it is not the
launched E amplitude. Normal incidence only; use a fixed
record length (``until_decay`` has no source-off contract).
These new source inputs support ordinary ``forward`` and its
internal checkpoints. External JAX transformations, including
grad/value_and_grad/JVP/vmap/jit/checkpoint/scan, are refused:
transformed scattered-field traces do not meet the cross-trace bar.

method : {"bloch", "methodB"} Oblique-incidence engine (ignored for angle_deg=0, which always uses the normal 1D-aux path). "bloch" (default) is the narrowband complex-Bloch 2D-aux path (fields go complex64; frequency-domain monitors are not transform-aware — see tfsf_2d.py). "methodB" is the open-domain oblique path (real float32, 1D-aux-along-k̂ + 4-edge box): the transverse axis becomes OPEN/CPML, z stays thin periodic (2.5-D, z-invariant far field), and NTFF/DFT/flux monitors read physical fields. "methodB" currently requires polarization='ez' and a single-device uniform grid.

def tfsf_box_indices(self) -> dict[str, tuple[int, int]]:

Realized inclusive total-field node bounds on the padded grid.

The normal slab returns x only; closed_box=True returns x/y/z. NTFF cell-centre interpolation must clear these bounds, including the adjacent Yee samples (the NTFF placement check enforces this).

def add_waveguide_port( self, x_position: float, *, x_range: tuple[float, float] | None = None, y_range: tuple[float, float] | None = None, z_range: tuple[float, float] | None = None, mode: tuple[int, int] = (1, 0), mode_type: str = 'TE', direction: str = '+x', freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 50, f0: float | None = None, bandwidth: float = 0.5, amplitude: float = 1.0, probe_offset: int = 10, ref_offset: int = 3, calibration_preset: str | None = None, reference_plane: float | None = None, probe_plane: float | None = None, name: str | None = None, n_modes: int = 1, waveform: str = 'modulated_gaussian', mode_profile: str = 'discrete') -> Simulation:

Add a rectangular waveguide port.

x_position is interpreted along the selected port-normal axis from direction. For example, direction='-y' uses x_position as the physical y-coordinate of the port plane.

Current scope supports axis-normal boundary ports using rectangular apertures, with boundary='cpml' and mode='3d'.

Calibration options:

  • reference_plane / probe_plane: explicit reported planes in physical coordinates along the port normal axis
  • calibration_preset='source_to_probe': auto-report S11 at the snapped source plane and S21 from source to the snapped probe plane
  • calibration_preset=None or 'measured': use the snapped stored reference/probe planes directly

Sampling still occurs on the nearest snapped grid planes, and the result metadata reports those actual measurement planes explicitly.

waveform selects the source pulse shape. Default "modulated_gaussian" is the Meep-style bandpass pulse — no sub-cutoff DC content, so the in-band TFSF filter collapses to identity, reducing directional leakage in the H+E injection pair. "differentiated_gaussian" matches historical (legacy) rfx behaviour. Preliminary measurement shows directionality backward/forward ratio 13.3 % → 8.2 % for a WR-90 at f₀=10 GHz; further gains await the discrete-eigenmode profile work (P3).

def boundary_model(self, *, rcs: bool = False):

Return the B1 face declaration with the current feature requirements.

No runner consumes this descriptor in B1. rcs=True collects the RCS requirement for a caller preparing that operation.

def add_floquet_port( self, position: float, *, axis: str = 'z', scan_theta: float = 0.0, scan_phi: float = 0.0, polarization: str = 'te', n_modes: int = 1, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 50, f0: float | None = None, bandwidth: float = 0.5, amplitude: float = 1.0, name: str | None = None) -> Simulation:

Add a Floquet port for periodic structure / phased array analysis.

The Floquet port injects a plane wave at the given scan angle and extracts Floquet mode amplitudes (S-parameters) from the unit cell response. Requires periodic BC on the two axes perpendicular to the port normal.

Parameters

position : float Physical coordinate along the port normal axis (metres). axis : str Port normal axis: "x", "y", or "z". scan_theta : float Scan angle theta from broadside (degrees). Default 0. scan_phi : float Scan angle phi in the transverse plane (degrees). Default 0. polarization : str "te" or "tm". Default "te". n_modes : int Number of Floquet modes to extract (default 1 = specular). freqs : array or None Analysis frequencies. Auto-generated if None. n_freqs : int Number of frequency points when freqs is None. f0 : float or None Source center frequency. Default: freq_max / 2. bandwidth : float Source fractional bandwidth. Default 0.5. amplitude : float Source amplitude. Default 1.0. name : str or None Optional name for the port. Auto-generated if None.

def add_probe( self, position: tuple[float, float, float], component: str = 'ez') -> Simulation:

Add a point field probe.

def add_vector_probe(self, position: tuple[float, float, float]) -> Simulation:

Add a vector probe that records ALL 6 field components.

Records Ex, Ey, Ez, Hx, Hy, Hz at the same position. Results accessible via result.time_series columns [0..5].

def add_dft_plane_probe( self, *, axis: str, coordinate: float, component: str = 'ez', freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 50, name: str | None = None, region: tuple[int, int, int, int] | None = None) -> Simulation:

Add a frequency-domain 2D plane probe.

Parameters

axis : "x", "y", or "z" Plane normal axis. coordinate : float Physical coordinate in metres along the selected axis. component : field component name One of ex/ey/ez/hx/hy/hz. freqs : array or None Probe frequencies in Hz. Default: linspace(freq_max/10, freq_max, n_freqs). n_freqs : int Number of frequencies if freqs is None. region : tuple or None Half-open transverse array-index crop (lo1, hi1, lo2, hi2). A 1 by 1 crop is a point DFT at that Yee component. name : str or None Optional result key.

def add_flux_monitor( self, *, axis: str, coordinate: float, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 50, size: tuple[float, float] | None = None, center: tuple[float, float] | None = None, name: str | None = None, dft_window: str = 'rect', dft_window_alpha: float = 0.25) -> Simulation:

Add a Poynting flux monitor on a plane (Meep flux-region equivalent).

Accumulates frequency-domain E and H tangential components to compute integral Re(E x H*) . n_hat dA at each frequency.

Parameters

axis : "x", "y", or "z" Plane normal axis. coordinate : float Physical coordinate in metres along the selected axis. freqs : array or None Monitor frequencies in Hz. n_freqs : int Number of frequencies if freqs is None. size : (float, float) or None Positive finite extents in the two tangential directions. A finite window snaps to cell edges, clamps to the physical interior with a warning on any clamp, and excludes CPML/bounding-node slots. None means the full allocated plane (legacy behaviour). center : (float, float) or None Physical centre of the flux region in the two tangential directions. None defaults to the declared domain midpoint on uniform grids and the realized interior midpoint on graded grids. For example, for an x-normal monitor the two tangential axes are (y, z). Exactly two finite values are required. Preflight reports finite requested/realized bounds in metres through report.flux_regions. name : str or None Result key. Default: flux_{axis}_{idx}.

def add_ntff_box( self, corner_lo: tuple[float, float, float], corner_hi: tuple[float, float, float], freqs=None, n_freqs: int = 50) -> Simulation:

Add a near-to-far-field transform box for radiation patterns.

Parameters

corner_lo, corner_hi : (x, y, z) in metres Opposite corners of the Huygens box. freqs : array or None Frequencies (Hz). Default: n_freqs points from freq_max/10 to freq_max. n_freqs : int Number of frequencies if freqs is None.

def add_current_moment_monitor( self, corner_lo: tuple[float, float, float], corner_hi: tuple[float, float, float], block_size: float, freqs, order: int = 2, margin_cells=0, off_cells: int = 0) -> Simulation:

Accumulate the structure's own current as block moments, in-loop.

The radiation of a structure is what the current in it radiates. On the Yee lattice that current is an identity the solver already enforces — J = curl_h H - eps0 dE/dt at every electric-field edge — so it can be read off the fields the step is holding, with nothing modelled and nothing fitted. This monitor sums it over the slab between corner_lo and corner_hi into a few numbers per in-plane block (the total current moment and its first two spatial moments about the block's own centre) and DFTs those, instead of accumulating tangential E and H over a Huygens surface.

Parameters

corner_lo, corner_hi : (x, y, z) in metres Opposite corners of the slab, in the same frame as add_ntff_box(). The z range picks the node planes; the whole thickness goes into one block. block_size : float In-plane block side in metres, rounded to a whole number of cells. The realized side is what the monitor reports. freqs : array Frequencies (Hz). order : int 2 (the only accepted value) keeps P, Q and T — 30 numbers per block. Lower orders are refused; the low-level rfx.current_moments.build_current_moment_monitor keeps them.

Notes

margin_cells : int or (mx, my, mz) Extra cells around the declared corners. off_cells : int Shift of the in-plane partition origin, in cells. 0 and half a block are the two the block rule was measured with.

Notes

The slab must lie in the interior: inside the absorber the E update is not Ampere's law, so a current read there is the absorber's fiction. Periodic/Bloch axes, TFSF sources, stencil_order=4, a graded or dx != dy in-plane mesh, a traced mesh profile, and every lane whose scan body does not accumulate the monitor are refused rather than approximated. So is a model the pattern would silently leave out: magnetic material (mu_r != 1), a PEC or PMC domain face, a waveguide, coaxial or Floquet port, a microstrip port with mode="eigenmode", and any dielectric, conductor, dispersive cell, port, lumped element or source that is not inside the slab (its outermost edge layer counts as outside).

The named arguments here are the whole public surface. The low-level builder additionally takes deliberately wrong metrics, centres, curl signs and time stamps so the mutation harness can measure what the declared checks catch; those never reach a user's declaration.

def freeze_mesh(self):

Finalize mesh spacing/profiles and extent, returning the selected grid.

Call after adding mesh-driving materials/geometry and before placing lattice-aligned conductors. Later geometry cannot refine this mesh; unresolved features still raise under the normal conductor contract. Repeated calls preserve the same mesh. Build a new Simulation to remesh.

Grid previews (including preflight) alone do not finalize the mesh. Register boundary conditions and ports before retaining grid indices: port registration may change padding, though physical node positions in the domain stay fixed. Caller declarations remain available unchanged.

def mesh_intelligence_report( self, *, n_steps: int | None = None, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, available_memory_gb: float | None = None, n_warmup: int = 0, check_ntff: bool = True, check_resolution: bool = True) -> MeshIntelligenceReport:

Return a consolidated mesh-quality and memory-planning report.

The report is intentionally advisory: it reuses preflight() for physics/geometry warnings, reuses estimate_ad_memory() when n_steps is provided, and adds a uniform-fine comparator that estimates how many cells a globally fine mesh would need if it used the minimum cell size present in any non-uniform profile.

This is the Stage-1 production-near "subgrid-like" planning surface: it helps users decide whether existing non-uniform mesh plus segmented checkpointing is enough before attempting research-only true subgridding.

checkpoint_every and checkpoint_segments are forwarded to the same AD estimator used by plan_ad_memory(); they are mutually exclusive. n_warmup is forwarded as reverse-mode tape metadata only, matching estimate_ad_memory.

def plan_mesh( self, *, n_steps: int | None = None, checkpoint_every: int | None = None, available_memory_gb: float | None = None, sparameter_calculator: str | None = None, artifact_root: str | None = None) -> MeshPlan:

Return an advisory mesh plan for this configured simulation.

@classmethod
def auto( cls, freq_range: tuple[float, float], *, accuracy: str = 'standard', **kwargs) -> Simulation:

Create a Simulation with auto-derived parameters.

Parameters

freq_range : (f_min, f_max) in Hz Analysis frequency range. All parameters (dx, domain, CPML, n_steps) are derived from this. accuracy : "draft", "standard", or "high" **kwargs : additional Simulation constructor args (override auto values)

Returns

Simulation with optimal configuration for the frequency range.

Example

>>> sim = Simulation.auto(freq_range=(1.5e9, 3.5e9))
>>> sim.add(Box(...), material="pec")
>>> result = sim.run(until_decay=1e-5)
>>> modes = result.find_resonances()
def preflight( self, *, strict: bool = False, check_ntff: bool | str = True, check_resolution: bool = True, check_ad_memory: bool = False, n_steps_for_memory: int | None = None, available_memory_gb: float | None = None) -> rfx.preflight._common.PreflightReport:

Run all pre-simulation checks and return warnings.

Resolves and caches the mesh from the current static declaration, without changing declared dx/domain/profiles. Auto-mesh selection is emitted as a UserWarning, separately from validation findings.

Parameters

strict : bool If True, raise ValueError on the first issue instead of collecting warnings. check_ntff : bool or "advisory" True (default): run the full NTFF check family (PEC-overlap hard error + λ/4 / λ/2 near-field gap advisories + the sub-wavelength ground-plane pattern advisory, issue #334). "advisory": run only the advisories — the tier run() uses, because the λ/4 and small-ground-plane warnings are physics-relevant to any far-field computation while the PEC-overlap hard error remains an inverse-design gate (issue #303). False: skip the family entirely. check_resolution : bool Run the tightened resolution check (existing _validate_mesh_quality uses per-material thresholds already — this flag kept for symmetry and future tightening). Default True. check_ad_memory : bool Run AD memory estimate and warn if > 85% of available VRAM. Requires n_steps_for_memory. Default False (diagnostic only). n_steps_for_memory : int or None Step count for AD memory sizing. Required when check_ad_memory. available_memory_gb : float or None Override VRAM detection. If None, best-effort via JAX devices.

Returns

PreflightReport A list subclass of PreflightIssue (each a str subclass), back-compatible with the legacy list[str] return. Empty if no issues found. Finite flux-window geometry is recorded separately in flux_regions, including for issue-free reports.

def preflight_sparameters( self, *, calculator: str = 'run', strict: bool = False, normalize: bool | str | None = None, include_general: bool = False) -> rfx.preflight._common.PreflightReport:

Preflight the selected S-parameter calculator without running FDTD.

This is a routing/contract check for the port-family-specific S-parameter APIs. It answers "which calculator should this simulation use?" before an expensive run starts:

  • calculator="run" checks run(compute_s_params=True) for lumped/wire add_port(...) families.
  • calculator="forward" checks forward(port_s11_freqs=...) for uniform single-device S11 vectors.
  • calculator="msl" checks compute_msl_s_matrix(...).
  • calculator="waveguide" checks compute_waveguide_s_matrix(...).

Parameters

calculator: One of "run", "forward", "msl", or "waveguide" (the corresponding method names are accepted as aliases). strict: If True, escalate ERROR-severity findings to a raise: collect everything, then raise a single ValueError listing the errors (aggregate-then-raise). The underlying NotImplementedError is recorded as an error-severity issue and re-surfaced as part of that aggregated ValueError (its exact type is not preserved).

Errors only, not "any issue": the waveguide setup audits report
advisory and informational findings on a perfectly valid routing,
and a healthy two-port guide ALWAYS carries the informational
E-plane note, so escalating on emptiness would make
``strict=True`` raise on every correct waveguide setup. Advisories
are still returned in the report and printed; read them there.
(This differs from ``preflight(strict=True)``, whose report has no
informational tier.)

normalize: Waveguide non-uniform preflight uses this to mirror compute_waveguide_s_matrix(normalize=...). None means the method default, currently False. include_general: If True, append the ordinary geometry/material preflight() issues after the S-parameter routing check.

Returns

PreflightReport A list subclass of PreflightIssue (back-compatible with the historical list[str]). Carries no ERROR-severity issue when the selected calculator is valid for the registered port families — read report.ok / report.errors rather than emptiness. calculator="waveguide" additionally runs the three setup audits (_validate_cfg_record_vs_far_boundary(), _validate_cfg_port_index_mirror_covariance() and _validate_cfg_layout_from_band_low_edge()), which report advisory and informational findings on a perfectly valid routing — a healthy two-port guide always carries at least the known E-plane-offset note. Those audits are evaluated at compute_waveguide_s_matrix's own default num_periods (WAVEGUIDE_DEFAULT_NUM_PERIODS); the record-length message names the num_periods that clears the threshold, which does not depend on the value it was evaluated at.

def compute_msl_s_matrix( self, *, n_steps: int | None = None, num_periods: float = 40.0, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 100, raw_3probe_dump_path: str | None = None, strict_extractor: bool = False, eps_override: jax.jaxlib._jax.Array | None = None, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, enforce_passivity: bool = False, report_every: int | None = None) -> MSLSMatrixResult:

Compute MSL S from probe-plane V/I with fitted line diagnostics.

Returned S uses power waves for the positive real per-port analytic references: a=(V+RI)/(2sqrt(R)), b=(V-RI)/(2sqrt(R)). reference_impedances records those R values separately from the fitted Z0 and source/load resistances. For unequal references, transmission S differs from the legacy voltage ratio by sqrt(R_input/R_output); equal-reference results are unchanged.

S is the RAW extracted S-matrix on every channel — concrete, eps_override, traced — and the default enforce_passivity=False is what makes that one statement instead of three. The projection below cannot run under tracing or on the eps_override channel (it would zero the gradient wherever the clip bites), so while it was the default the S a caller measured and the S jax.grad differentiated were different functions wherever it bit. One function, chosen to be the measured one (PI decision, 2026-09-21; the contract is docs/design_notes/chain_closure_contract.md).

A raw S may exceed the passive bound, and the result says so rather than hiding it: sigma_max_excess[k] = max(sigma_max(S(f_k)) - 1, 0) is filled on every concrete call (None only under tracing), and a warning names the bin count and the worst sigma_max whenever any bin exceeds 0. A passive structure cannot scatter more power than it receives, so such a bin is a measurement artifact — the usual causes are a record that ended before the structure rang down and a mesh too coarse for the geometry (settling_db and reliable say which).

enforce_passivity=True opts back into the post-process: the assembled S(f) is projected onto the passive set per frequency (singular values clipped to 1 — the nearest matrix in spectral norm with ||S||_2 <= 1), the unprojected matrix is kept in S_raw, the per-bin clip amount in passivity_correction, and a warning names the touched bins. It is constraint enforcement, not a physics fix, and it is NOT small where the raw extraction is bad: on a thru fixture whose raw sigma_max ran 1.19-1.91, projecting moved |S21| 1.000 -> 0.61-0.72 and rotated its phase by up to 17 degrees, while Z0 and beta stay raw. Never quote projected values as physics where passivity_correction is large.

EXEMPTION (unchanged): even with enforce_passivity=True no projection is applied under tracing or on the eps_override channel (traced or concrete), so that finite-difference and jax.grad objectives see the same function; S is then the raw extraction with S_raw/passivity_correction absent and no projection warning. sigma_max_excess is still filled on the concrete eps_override channel, so the bound violation stays visible there (measured sigma_max 1.18 on a coarse thru).

Surface-impedance sheets (add_thin_conductor(..., surface_impedance_f0=...)) are supported on this lane (#677/#679): the sheet is realized node-thin by the per-step operator inside the run()/forward() device dispatches — no lane-level ctx is built here. Combination refusals fire downstream at whichever lane entry the call actually reaches, and the two channels carry their OWN copies: on run() the dispersive-substrate and boundary='upml' refusals come from run_uniform with run-lane wording, while the forward() / eps_override channel never enters run_uniform and raises instead from the forward-lane entry in rfx/api/_execute.py, naming that lane. #679 added the upml half there: before it, an eps_override call on a boundary='upml' sim silently ran the very combination run() refuses (the sheet operator overwriting UPML's split-field E update at its edges). ADI refuses on both channels through the shared refuse_f0_sheets helper; the subgridded and distributed lanes carry their own call sites at whichever entry reaches them — FENCE_REGISTRY in tests/unit/materials/test_sheet_impedance.py is the authoritative per-lane list, AST-guarded against drift. subpixel_smoothing / conformal_pec are run()-only keywords, so that combination is unreachable on the eps_override channel rather than refused there. The TRACE must remain PEC: an f0 sheet realizes no PEC edge, and the closed Ampere-loop current and the V span anchor on the realized PEC wall planes (#931 §1.9 — a PEC trace may be a volume Box or a sheet; both are found). The Hammerstad-Jensen beta/Z0 anchors and the real-beta N-probe fit assume a lossless line, so a sheet lying INSIDE a probed span adds per-length loss the fit cannot represent — reported Z0/q shift (the Z0 honesty guard may warn) while the V-I production S stays valid. A non-z-normal sheet (x/y-normal) carries Ez in its tangential edge set: crossing the feed plane or the V-integration column it legitimately modifies the measured/launched Ez — place sheets clear of feed and probe planes.

For each registered MSL port, runs one FDTD simulation with that port driven and the others passive. The passive ports are NOT assumed matched — |a_passive/a_driven| = 0.243-0.248 at the shipped R = 50 Ω (0.19-0.93 across terminations), re-measured on current main 2026-08-30 (#524, VESSL 369367257265, PR #799; the July figure quoted here before was 0.07-0.51) — so the S-matrix is recovered by solving the full wave system S = B·A⁻¹ over all drives rather than by the per-column ratio b_j/a_d (issue #507; the ratio reported the far port's echo as the structure's own reflection). At each port n_probes downstream DFT plane probes record Ez and the first probe also records the transverse H components for a closed Ampere-loop current. Production S is formed from this first-plane V/I and the analytic Hammerstad-Jensen reference impedance. The N-probe least-squares fit of V_n = α e^{-jβx_n} + γ e^{+jβx_n} supplies only the reported beta/Z0 diagnostics. Those fitted values do not enter S. S is referenced to each first probe plane, without translation back to the physical feed planes. The two bracketing H planes are linearly interpolated to the E-node using their physical coordinates, then the leapfrog temporal offset is corrected. Comparing different observation offsets still requires accounting for reference planes and impedances. probe_clearance reports the separate downstream layout recommendation; it is not a signal or accuracy score.

Parameters

n_steps : int or None Timesteps per FDTD run. None → auto from num_periods. num_periods : float Source-period multiples used to derive n_steps when not provided. Default 40 (MSL transients are slow to drain). freqs : array, optional Frequency grid. Defaults to linspace(freq_max / 10, freq_max, n_freqs). n_freqs : int Number of frequencies if freqs is None. raw_3probe_dump_path : str or None Optional .npz path. When provided, write the real simulation-derived N-probe voltage/current phasors used by the extractor, together with the production power-wave S-matrix, so the de-embedding can be independently checked without rerunning FDTD. The dump schema is rfx.msl_nprobe_dump v4: it explicitly records the power-wave convention and actual reference impedances. Legacy v3 records use voltage waves. raw_v has shape (n_driven, n_ports, n_probes_max, n_freqs). scripts/diagnostics/replay_msl_3probe_dump.py is SUPERSEDED for modern dumps (it expects the retired 3-probe/single-ratio v1 schema). Use rfx.validation.load_port_vi_dump_npz and replay_smatrix_from_port_vi_dump for independent S replay. strict_extractor : bool Honesty guard for the de-embedding (issue #80 Fix A). After extraction, the per-frequency |q| and extracted Z0 are validated against physical bounds (|q| <= 1 for a passive line; extracted Z0 within 10 % of the analytic Hammerstad-Jensen value). When False (default) a violation raises a loud warnings.warn(); when True it raises ValueError instead. With the N-probe extractor (Fix C) |q| and Z0 should be healthy so this rarely fires — it is the safety net for pathological geometries. enforce_passivity : bool Opt into the passivity projection as a post-process. False (default) returns the raw extracted S; see the section above for what each setting costs and which channels the projection can reach. Changed from True on 2026-09-21. checkpoint_segments : int or None Gradient-checkpointing segment count for the reverse-mode AD tape on the uniform mesh (the standard MSL path), forwarded to forward() (only active on the differentiable eps_override channel). Must DIVIDE the auto-computed n_steps exactly — padding is rejected because it would shift the DFT accumulator windows. Choose the divisor nearest sqrt(n_steps) so backward memory scales ~sqrt(n_steps)*carry instead of n_steps*carry — required for converged num_periods>=20 AD that otherwise OOMs (G-AD-CHECKPOINT). Default None leaves forward-only runs and small-period AD unchanged. checkpoint_every : int or None Non-uniform-mesh counterpart of checkpoint_segments (chunk size, not segment count; issue #73). Forwarded to forward(); raises NotImplementedError on the uniform path — use checkpoint_segments there. report_every : int or None Issue #667 — progress reporting for long solves. Forwarded to each per-drive run() call, tagged MSL drive pI/N so the drives are distinguishable in a log. None (default) is OFF and leaves the solve byte-identical. The measured case this exists for: a 42.15 M-cell / 225,000-step call whose last log line was written at second 0 and was still the last line 4 h 10 min later, with no way to tell slow from wedged. Ignored (with a warning) on the differentiable eps_override channel, which routes through the traced forward().

Returns

MSLSMatrixResult

def compute_coaxial_line_reflection( self, *, termination: str = 'short', n_steps: int = 6000, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 11, field_scale: float = 10000.0, cpml_axes: str | None = None, dut_offset_cells: int = 4, probe_count: int = 12, probe_start_cells: int = 8, probe_spacing_cells: int = 4, feed_impedance: float | None = None, dut_impedance: float | None = None, eps_scale: jax.jaxlib._jax.Array | float | None = None) -> rfx.api._spec.CoaxialLineReflectionResult:

One-port coaxial reflection on a real transmission line (broad-E5).

Builds a coextensive coax line (face='top', pin along -z) terminated in a matched resistive feed near the +z boundary, drives a TEM TFSF source one cell below the feed, and reflects off a calibration termination at the -z end: "short" (Γ=-1), "open" (Γ=+1), or "matched" (Γ→0). With termination="matched" and dut_impedance=R the DUT is instead a known resistive load (analytic Γ=(R-Z0)/(R+Z0)) — used by the broad-E5 envelope to test non-trivial reflection magnitudes against exact truth. The reflection is read from the modal voltage V(z)=∫E_r dr sampled at probe_count equally spaced planes and a matrix-pencil estimate of the complex propagation constant (β self-measured, Z0-free).

Unlike the single-plane coaxial lane removed in #1212 (V/I in a hardcoded closed PEC box — non-physical |S11|>1), this uses an absorbing CPML feed so a real line exists. Resolution recipe: keep ≥~4 cells across the (outer-inner) annulus (raise freq_max to shrink dx); the result status reports "under_resolved" below ~3.5 cells.

The conductors deliberately stop ~2 cells short of the +z PML — running PEC into CPML is numerically unstable.

The line sits in open space: the run absorbs on all three axes (cpml_axes=None reads the grid; all three axes are required), so what leaves the line's unshielded ends — the open termination, and the pin and shell ending one cell past the matched feed — is absorbed as it would radiate from a cable. cpml_axes="z" is refused: it left the lateral pads as vacuum backed by the PEC grid faces, a closed metal can that held that field, and the open's |S11| rose above 1 and grew with the record (issue 1218).

The enclosing Simulation must use float32 precision and the three-dimensional, second-order uniform Yee solver with boundary="cpml" and cpml_layers > 0. Unsupported precision, solver, grid, boundary, TFSF, and refinement settings raise before the grid is built. The run requires positive CPML thickness on all six faces and no periodic-axis override, and the line's conductors must keep at least one whole cell from every lateral absorber (the stamper refuses a line without that room).

This method constructs its own coaxial line, TEM source, DFT planes, and termination. Do not add separate geometry, thin conductors, lumped RLC elements, probes or field monitors, or NTFF boxes; those registrations are rejected rather than ignored. Use the documented Simulation, port, and method arguments instead.

The registered coaxial port supplies its x/y centre, face, inner and outer radii, and excitation waveform. The method derives its axial layout internally, so the port's z coordinate and pin_length do not place the line, and the port's impedance does not set either load. Use feed_impedance for the feed and dut_impedance only with termination="matched". probe_count must be an integer of at least three, and every requested plane must fit between the DUT and source; otherwise the method raises before starting the FDTD run.

Differentiable (eps_scale)

Pass eps_scale (a scalar or (nx, ny, nz) jnp array) to make the reflection differentiable w.r.t. the dielectric under jax.grad. It MULTIPLIES the stamped eps_r (eps_r <- eps_r * eps_scale), applied AFTER the numpy conductor/dielectric stamps so the fixed geometry (PTFE fill in eps_r, PEC pin/shell in sigma) is preserved and only modulated — a well-conditioned design channel (unlike replacing the fill with air). When provided, the field→voltage→reflection assembly runs on the jax.numpy path (coaxial_line_plane_voltage_jnp + the traced extractor) so the gradient flows design → FDTD → DFT planes → Γ. With eps_scale=None the result is byte-identical to the validated numpy path. The AD↔FD gate is tests/unit/autodiff/test_coax_end_to_end_ad.py.

def compute_coaxial_two_port( self, *, n_steps: int = 6000, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 11, field_scale: float = 10000.0, cpml_axes: str | None = None, probe_count: int = 12, probe_start_cells: int = 8, probe_spacing_cells: int = 4, feed_impedance: float | None = None, cond_warn: float = 1000.0, strict_passivity: bool = False, eps_scale: jax.jaxlib._jax.Array | float | None = None, extra_flux_monitors: list | None = None) -> rfx.api._spec.CoaxialTwoPortResult:

Two-drive coaxial 2-port S-parameters (#489 stage 2) — VALIDATED WITH SCOPE.

VALIDATED WITH SCOPE, not EXPERIMENTAL (issue #489, PI decision 2026-08-06 — docs/guides/sparameter_support_matrix.md, the S-parameter family companion where this row lives). Covered: the two-port through-line class on this single coax geometry family, bracketed by an external openEMS referee (crossval 21) on |S21| and, via the port's own measured beta, phase; a mesh-refinement convergence witness (p ~= 1.5); an end-to-end eps_scale AD gate (GRAD_SAFE); and this method's own measured reciprocity/cond(A). NOT covered: every DUT this method can currently gate against is azimuthally symmetric (TM0n only) — transition discontinuities that excite TE11 are still outside this evidence (see compute_coax_msl_transition(), which stays EXPERIMENTAL, diagnostic-only), nor does the evidence generalize to other coax geometry families. See the class docstring on the returned CoaxialTwoPortResult for the full evidence chain and scope statement.

Builds ONE through coax line spanning the z axis with a matched annular-resistor feed near EACH z end (mirroring the validated 1-port compute_coaxial_line_reflection() layout at both ends: each feed sits between that end's own TEM TFSF source and that end's own CPML, i.e. strictly on the scattered-only side of that drive's TFSF boundary — never in the path of that drive's own launched wave), then drives each end's source in turn (two separate FDTD runs). A probe array of probe_count equally spaced planes near each end recovers that array's local two-wave decomposition (matrix-pencil, Z0-free, same machinery as the 1-port method); the forward/back amplitudes are evaluated at each port's OWN reference plane (its feed's axial position) and assembled into a full 2x2 S-matrix via rfx.sources.coaxial_port. solve_two_port_from_wave_amplitudes() — the two-drive solve that does NOT assume the non-driven port sees zero incident wave (unlike the naive S[j,i] = b_j/a_i ratio, which has a hard terminator- reflection floor on a through line; see that function's docstring and docs/research_notes/20260729_i489_coax_two_port_design.md).

Port 1 is the +z end (mirrors the 1-port fixture's own face='top' orientation); port 2 is the -z end (mirror image, face='bottom'). Both drives share the SAME registered add_coaxial_port(...) geometry (x/y centre, pin/outer radii) and excitation waveform; the registered port's own position/face/pin_length do not place either end of the line (mirrors the 1-port method's own contract). Requires port.face == 'top' (arbitrary but consistent with the 1-port method; the value is not otherwise used to orient this method's own internally-built fixture).

Unlike the 1-port method, the returned result is routed through _finalize_sparam_result() (which runs the passivity/finiteness self-check via _warn_if_nonpassive_smatrix()) before being returned — the 1-port CoaxialLineReflectionResult bypasses that check (design-note incidental defect 3); this result does not.

This method constructs its own coaxial line, TEM sources, DFT planes, and feeds. Do not add separate geometry, thin conductors, lumped RLC elements, probes or field monitors, NTFF boxes, or TFSF sources; those registrations are rejected rather than ignored.

Same solver/precision/boundary contract as compute_coaxial_line_reflection() (float32, 3D uniform Yee, boundary='cpml' with positive CPML thickness on all six faces, cpml_axes='xyz', no periodic axes, no non-uniform mesh, no refinement). The run absorbs on all three axes: the line's two ends, one cell past each feed, are unshielded, and with absorbers on z only the lateral pads were a closed PEC can that held the field leaving them (issue 1218). cpml_axes='z' is refused.

Differentiable (eps_scale, #489 leg 3)

Pass eps_scale (a scalar or (nx, ny, nz) jnp array) to make the S-matrix differentiable w.r.t. the dielectric under jax.grad — same name, semantics, and MULTIPLIES-the-stamped-eps_r design as the 1-port compute_coaxial_line_reflection()'s own eps_scale (eps_r <- eps_r * eps_scale, applied once, after both feed stamps, so it reaches BOTH drives' FDTD runs — the through line has no DUT break to scope it to). When provided, both drives route their voltage extraction through rfx.sources.coaxial_port. coaxial_line_plane_voltage_jnp() (the same differentiable twin the 1-port path uses) instead of the concrete ~rfx.sources.coaxial_port.coaxial_line_plane_voltage(), and the assembly (_assemble_coaxial_two_port_from_voltages()) and the two-drive solve (~rfx.sources.coaxial_port. solve_two_port_from_wave_amplitudes()) both dispatch to their own jnp cores. With eps_scale=None the result is byte-identical to the validated numpy path (dual-path design, not a rewrite — mirrors the 1-port method's own contract). The per-drive ring-down settling_db witness needs a concrete time series and is skipped on this path (stays nan, same reasoning as the 1-port method's own "can't Python-branch on a tracer" note on its rec_resid contamination check); status is therefore "under_resolved" or "differentiable" here, never "contaminated"/"passed". cond_warn is also silently INERT on this path: the ill- conditioning warning it controls is Python control flow keyed on a concrete cond(A) value (rfx.sources.coaxial_port.solve_two_port_from_wave_amplitudes()'s NumPy branch), which the traced jnp core (_solve_two_port_from_wave_amplitudes_jnp) cannot evaluate and does not attempt to — this mirrors the settling_db/status losses above (same "can't Python-branch on a tracer" reason), not a separate defect; cond_a is still RETURNED (as a tracer), so a caller can inspect it after concretizing the result if degeneracy matters to them. The AD gate is tests/unit/autodiff/test_coax_two_port_ad.py.

extra_flux_monitors (issue #589 flux-adjudication instrument): an ENERGY-WITNESS channel, not an extractor change. Pass the entry objects Simulation.add_flux_monitor registers (build them on a scratch Simulation with the same domain and hand over its ._flux_monitors); each internal drive run then accumulates the requested Poynting-flux planes, and per-drive spectra come back name-keyed on result.flux_monitors ({port_name: {monitor_name: (n_monitor_freqs,) float64}}, net flux, positive = +axis). The S-parameter math is untouched — the non-perturbation witness (S bit-identical with and without monitors) is gated in tests/unit/sparams/test_coax_msl_transition.py::test_extra_flux_monitors_do_not_perturb_s. The registered-monitor guard is unchanged: monitors registered ON this sim still raise, because this method builds its own probes.

def compute_coax_msl_transition( self, *, junction_x: float, eps_r_sub: float | None = None, n_steps: int | None = None, num_periods: float = 30.0, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 11, field_scale: float = 10000.0, probe_count: int = 8, probe_start_cells: int = 6, probe_spacing_cells: int = 3, msl_probe_count: int | None = None, msl_probe_start_cells: int | None = None, msl_probe_spacing_cells: int | None = None, feed_impedance: float | None = None, cond_warn: float = 1000.0, strict_passivity: bool = True, skip_preflight: bool = False, extra_flux_monitors: list | None = None, return_ladder_voltages: bool = False) -> rfx.api._spec.CoaxMSLTransitionResult:

EXPERIMENTAL coax<->microstrip transition 2-port S-parameters (issue #489 leg 4).

EXPERIMENTAL — not in the validated set (docs/guides/sparameter_support_matrix.md / .json). One pre-declared fixture has been run against this method; see that fixture's own predeclaration (tests/unit/sparams/test_coax_msl_transition.py) for the measured envelope. NOT_TRACEABLE (see ~rfx.api._spec. CoaxMSLTransitionResult's class docstring for the full honesty contract, including why the MSL side is extracted via the coax matrix-pencil fit rather than the diagnostic-only N-probe fit

488 uses). Since issue #838 this lane REFUSES by default: a

non-passive extracted S raises ValueError unless the caller passes strict_passivity=False, which returns the diagnostic matrix with a UserWarning instead.

Generalizes issue #488's mixed lumped/wire<->MSL assembler (compute_mixed_s_matrix()) to a coax<->MSL pair by combining, UNCHANGED, the less-invasive half of each family's own validated machinery instead of writing a new geometry-specific extractor:

  • The coax side is built exactly like compute_coaxial_two_port()'s own single-ended stub (CPML, TEM TFSF source, matched annular- resistor feed, then a probe array), reusing ~rfx.sources.coaxial_port.stamp_coaxial_line(), ~rfx.sources.coaxial_port.stamp_coaxial_annular_resistor(), and ~rfx.sources.coaxial_port.build_coaxial_tem_plane_source_specs() verbatim — but only ONE end (this method has no second coax port).
  • The MSL side is consumed exactly like compute_mixed_s_matrix() consumes its MSL ports: the caller registers arbitrary DUT geometry (substrate, trace, ground plane, and — unique to this transition — the ground-plane clearance hole and the vertical pin-to-trace post that connects the two families) via the ordinary sim.add(Box(...)/Cylinder(...), material=...) API, and this method reuses ~rfx.sources.msl_port.compute_msl_mode_profile(), ~rfx.sources.msl_port.setup_msl_port(), and ~rfx.sources.msl_port.make_msl_port_sources() verbatim for the MSL port's own termination/excitation.

This method does NOT build the junction geometry itself (the ground plane, its clearance hole, or the pin-to-trace post) — those are DUT-specific and belong to the caller's own registered geometry, exactly as compute_mixed_s_matrix() never builds its own substrate/trace. What this method DOES fix, by construction, is WHERE each port's own S-parameter reference plane sits: both are placed AT the physical launch discontinuity (see junction_x below and port.position[2] on the registered add_coaxial_port()), specifically to minimize the pre-declared "reference-plane mismatch" failure mode (the coax's axial z-feed-plane convention has no direct analogue in the MSL's along-trace x reference plane — these are different geometric axes entirely).

Registration contract

Exactly one add_coaxial_port() (face='bottom' — the physical convention this method assumes: the coax stub is built FROM the domain's low-z CPML face UP TO position[2] (rounded to the nearest grid z-node, half-cell ties to the lower node under the conductor-plane convention), where position[0], position[1] is the coax axis centre (x, y) and position[2] is the physical height of the caller's OWN registered ground-plane conductor — i.e. this is the ONE parameter that ties this method's auto-built coax stub to the caller's own junction geometry; get it wrong and the pin will either dangle in a gap or overlap the caller's own PEC). Exactly one add_msl_port(), whose position[2] (substrate bottom / ground height) MUST equal the coax port's position[2] to within one grid cell — both refer to the SAME physical ground plane. No other ports (lumped/wire/waveguide/Floquet), no TFSF, no lumped RLC, no pre-registered probes/DFT planes/flux monitors/NTFF (this method builds its own). self._geometry must be non-empty (the caller's substrate/trace/ground-plane/pin-post Boxes and Cylinders). The MSL source interval must meet its realized conductor surfaces. The auto-built stub stops below the junction node and preserves the caller's materials at and above it. These checks do not certify the junction's connectivity or its modal accuracy. Same solver/precision/boundary contract as compute_coaxial_two_port() (float32, 3D uniform Yee, boundary='cpml' with positive CPML on all six faces, absorption on all three axes): the coax's own far end needs an absorbing z face exactly like the coax lane, the MSL trace radiates in x/y too, and the caller's own ground plane is an INTERNAL stamped/registered PEC layer rather than the domain's z boundary — this is also WHY this method cannot reuse a PEC z_lo domain boundary as the MSL ground reference the way compute_mixed_s_matrix()'s magnitude_channel="flux" fixtures do (that would conflict with the coax's own need for an absorbing z_lo face).

Two-drive extraction (never a naive single-ratio)

Drives the coax source, then the MSL source, in two separate FDTD runs (never assuming the non-driven port sees zero incident wave — the terminator-floor problem compute_coaxial_two_port() already solved for symmetric coax applies here too, and likely worse, since the coax feed resistor and the MSL Hammerstad-Jensen termination have no reason to share a termination quality). Each port's own forward/backward wave amplitudes are recovered from its own probe ladder via ~rfx.sources.coaxial_port. coaxial_line_reflection_from_plane_voltages() (Z0-free matrix-pencil fit — see ~rfx.api._spec.CoaxMSLTransitionResult for why this is used for the MSL side too, not the lane's own N-probe fit), converted to POWER waves via each port's own analytic reference impedance, and assembled by ~rfx.sources.coaxial_port.solve_two_port_from_wave_amplitudes() (the same generic two-drive solve compute_coaxial_two_port() uses). See _assemble_coax_msl_transition_from_voltages() for the full pure-assembly derivation.

Parameters

junction_x : float Physical x-coordinate (metres) of the coax-to-trace launch discontinuity — the MSL side's own S-parameter reference plane. Must match wherever the caller's own registered pin-to-trace post / trace Box actually begins; this method does not infer it from geometry (mirrors the coax side's own reference plane being the registered port's position[2], not inferred). eps_r_sub : float, optional Substrate relative permittivity. If None, taken from the registered add_msl_port()'s own eps_r_sub (which must then be set explicitly — this method does not attempt the geometry-bounding-box auto-detection add_msl_port() itself offers, to keep the eps anchor unambiguous for the Hammerstad-Jensen Z0 used in the power-wave normalization). probe_count, probe_start_cells, probe_spacing_cells : int The COAX side's own probe array (mirrors compute_coaxial_two_port()'s identically-named parameters — this method's coax stub is short by construction, between the near-source feed and the junction, so these three rarely need to grow). msl_probe_count, msl_probe_start_cells, msl_probe_spacing_cells : int, optional The MSL side's OWN probe array, independent of the coax parameters above (added issue #489 leg 4 attempt 2 — the two families' probe ladders were coupled through one shared set of parameters through attempt 1, which is fine when both ladders are short but breaks as soon as the MSL side needs a ladder spanning a meaningful fraction of a guided wavelength, since the coax stub's own short z-extent cannot host the same span). Each defaults to None, meaning "use the correspondingly-named coax parameter" — this preserves attempt 1's exact behavior (and its committed fixture's numbers) when left unset.

NEAR-FIELD STANDOFF (issue #823) — the one constraint these
three are NOT free of. Every probe must clear BOTH ends the
ladder is referred to (the MSL port's own feed plane AND the
reference plane at ``junction_x``) by at least
``max(3, round(5*h_sub/dx))`` cells. Both are launch
discontinuities, and within a few substrate thicknesses of one
the field is not the guided mode yet, so a matrix-pencil fit
that includes such a probe reports the LADDER's error as the
field's: on the settled attempt-3 run (VESSL 369367257533) a
single probe 0.4 mm = 1.33*h_sub from the feed dragged the
full-ladder residual from 3.7e-3 to 0.342, and this lane spent
three attempts attributing that to junction physics. The
threshold is the repo's EXISTING issue-#80 Fix B constant —
see `rfx.api._preflight.msl_source_near_field_standoff_cells()`
for the derivation that licenses reusing it here, and for the
W/h limitation it carries. Preflight cannot enforce it on this
lane (these are METHOD arguments and never reach the
registered ``_MSLPortEntry``), so this method evaluates the
same predicate on its own REALIZED ladder and emits a
``UserWarning`` naming the offending probes. REPORT-ONLY:
nothing is refused. Read it together with
``result.ladder_split_gamma_dev`` /
``result.ladder_split_reflection_decades`` (computed when
``return_ladder_voltages=True``, ``None`` otherwise), which say
whether the ladder actually disagrees with itself.

strict_passivity : bool, default True Default True: this lane REFUSES a non-passive extracted S, raising ValueError from the shared guard (_warn_if_nonpassive_smatrix() via _finalize_sparam_result()) instead of returning the matrix. Pass strict_passivity=False to get the diagnostic matrix back with a UserWarning instead of the raise. The default is True here and False on the single-family coax two-port lane (compute_coaxial_two_port()), which is unaffected by this (issue #838, PI decision 2026-09-20).

extra_flux_monitors (issue #589 flux-adjudication instrument): an ENERGY-WITNESS channel, not an extractor change. Pass the entry objects Simulation.add_flux_monitor registers (build them on a scratch Simulation with the same domain and hand over its ._flux_monitors); each internal drive run then accumulates the requested Poynting-flux planes, and per-drive spectra come back name-keyed on result.flux_monitors ({"coax"|"msl": {monitor_name: (n_monitor_freqs,) float64}}, net flux, positive = +axis). The S-parameter math is untouched — the non-perturbation witness (S bit-identical with and without monitors) is gated in tests/unit/sparams/test_coax_msl_transition.py::test_extra_flux_monitors_do_not_perturb_s. The registered-monitor guard is unchanged: monitors registered ON this sim still raise, because this method builds its own probes.

Unless skip_preflight=True, the shared sheet-size verdict runs before stepping: snap="strict" blocks an off-size sheet and snap="declared" warns. Other preflight families are not run here.

return_ladder_voltages (issue #589 label-independent ladder dump): a second read-only channel, additive in exactly the same sense. When True, the RAW per-probe modal voltages this method already computed for both drives are attached to result.ladder_voltages (None otherwise) so the ladders can be re-read OFFLINE — adjacent-pair phase slope (which way the dominant wave travels), standing-wave ratio, subset matrix-pencil fits — without a second FDTD run and without trusting any incident/outgoing LABEL. The dict is documented field-by-field on ~rfx.api._spec.CoaxMSLTransitionResult. It is built from .copy() of arrays that are complete before, and consumed by, _assemble_coax_msl_transition_from_voltages(), so every S-parameter number is bit-identical with the option off or on — gated by tests/unit/sparams/test_coax_msl_transition_ladder_dump.py:: test_return_ladder_voltages_does_not_perturb_s (byte-identity A/B, the same discipline as the flux witness above; the round-trip assertion there also proves the dump IS what the assembler consumed). The same flag switches on the issue-#823 LADDER SELF-CONSISTENCY WITNESS, result.ladder_split_gamma_dev / result.ladder_split_reflection_decades (None when the flag is off): a disjoint-half refit of each ladder, i.e. a Python loop of 2 drives x n_freqs x 2 matrix pencils per ladder over exactly the arrays the dump exposes. It is computed AFTER the assembler from those arrays and moves no S-parameter number; it is opt-in so that a default call pays for no extra pencil solves. Documented field-by-field on ~rfx.api._spec.CoaxMSLTransitionResult.

Returns

CoaxMSLTransitionResult

CONDUCTOR_SIGMA_THRESHOLD = 1000.0
def conductor_mask(self, grid=None, *, sigma_threshold: float | None = None):

Return the FULL conductor cell footprint of this simulation.

Since #677 a surface-impedance (surface_impedance_f0) thin conductor is a node-thin per-step operator: it touches neither pec_mask nor materials.sigma, so the obvious hand-written conductor test pec_mask | (sigma > 1e3) finds NOTHING for a board whose every trace is an f0 sheet and reports a healthy model as disconnected. There are now three places a conductor can live, and this accessor is the one spelling that covers all three::

pec_mask | (sigma > sigma_threshold) | union(f0 sheet masks)

Parameters

grid : Grid or NonUniformGrid or None Grid to rasterize against. None (default) builds the grid this simulation would actually run on -- the non-uniform grid when any of dx_profile / dy_profile / dz_profile is set, the uniform grid otherwise -- so the returned mask has the shape of the real run's arrays. sigma_threshold : float or None Conductivity (S/m) at or above which a cell counts as conductor. Default CONDUCTOR_SIGMA_THRESHOLD (1e3). The comparison is strict >, matching the sigma > 1e3 spelling this replaces.

Returns

jnp.ndarray Boolean array of grid.shape. All-False when the model has no conductor at all.

Notes

The f0 contribution is the sheet's rasterized CELL mask (the one-layer footprint apply_pec_mask would zero for the same shape), not the tangential EDGE masks the runtime operator applies. Cell footprint is what a connectivity / occupancy check wants; the edge masks live on SheetImpedanceCtx.

def distributed_override_layout(self, devices=None):

Return (shape, NamedSharding) for distributed forward designs.

The global shape is (nx_padded, ny, nz) with no ghosts, where nx_padded rounds the NU grid's x extent up to a multiple of the device count. Sharding is P("x") on the ordered devices (default: all global JAX devices). Every process must use the same list. Construct large designs with jax.make_array_from_callback or jax.make_array_from_single_device_arrays using this layout; no whole-domain device allocation is necessary. Forward ignores alignment-pad values, and their returned gradients are zero.

def shard_distributed_override(self, array, devices=None, *, pad_value=0.0):

Place a concrete local whole-domain design in the forward layout.

This convenience initializer copies addressable x slabs from a host array; it is not an autodiff operation. Differentiate forward with respect to the returned global array. For designs too large for one host, construct shards directly using distributed_override_layout. On multiple processes each caller supplies the same concrete array.

def forward( self, *, eps_override: jax.jaxlib._jax.Array | None = None, sigma_override: jax.jaxlib._jax.Array | None = None, mu_r_override: jax.jaxlib._jax.Array | None = None, design_box: tuple | object | None = None, design_eps_override: jax.jaxlib._jax.Array | None = None, design_sigma_override: jax.jaxlib._jax.Array | None = None, design_occupancy_override: jax.jaxlib._jax.Array | None = None, design_box_holds_ports: bool = False, pec_mask_override: jax.jaxlib._jax.Array | None = None, pec_occupancy_override: jax.jaxlib._jax.Array | None = None, n_steps: int | None = None, num_periods: float = 20.0, checkpoint: bool = True, gradient: str = 'autodiff', checkpoint_segments: int | None = None, emit_time_series: bool = True, checkpoint_every: int | None = None, n_warmup: int = 0, skip_preflight: bool = False, distributed: bool = False, devices: list | None = None, exchange_interval: int = 1, port_s11_freqs: object | None = None, rlc_values_override: dict | None = None, ringdown: rfx.ringdown.RingdownSpec | None = None, **_removed_kwargs) -> ForwardResult:

Run a minimal differentiable forward simulation.

This path is designed for jax.grad / jax.value_and_grad and intentionally returns only the observables needed by differentiable objectives instead of the broader stateful run() result.

Parameters

gradient : {"autodiff", "adjoint"} Opt-in settled-spectrum reciprocity design eps adjoint. Design sigma overrides are refused (#1424): on lossless design cells the conductivity gradient has no settled value in a finite record (a static sensitivity remains although the field decays); this adjoint returns settled-spectrum gradients only. Fixed lossy materials are allowed. Stores design-edge DFTs and runs a second ordinary forward. ForwardResult.adjoint_settling reads the primal design-edge max|E| over the last period of the lowest bin, capped at the record length, divided by the record maximum. Above about 1e-2 means unsettled; small proves nothing: it follows the strongest field and says nothing about the adjoint record. Use gradient_record_length_witness to witness an adjoint gradient. Uniform Yee design_box only; unsupported paths raise. Checkpoint options are unused by the adjoint (its storage does not depend on them). eps_override : jnp.ndarray or None Replacement permittivity array with shape grid.shape. With distributed=True, eps/sigma/occupancy overrides also accept x-sharded global arrays from distributed_override_layout(). That form is required across processes; gradients keep its sharding. Use shard_distributed_override() for a concrete local initializer. sigma_override : jnp.ndarray or None Replacement conductivity array with shape grid.shape. mu_r_override : jnp.ndarray or None Replacement relative permeability array with shape grid.shape. Differentiable (the H-update coefficient is dt/(mu_r*MU_0*dx)), so jax.grad flows w.r.t. a magnetic-material design variable (ferrite / magnetic-RAM inverse design). Uniform single-device lane only; a non-uniform mesh or distributed=True raises (it would otherwise be silently dropped — a zero-gradient no-op). design_box : (corner_lo, corner_hi) or a DesignRegion, or None Issue #1179. Bounding box, in metres, of a design region whose permittivity is handed in separately as design_eps_override instead of being written into a whole-grid eps_override. Accepts a (corner_lo, corner_hi) pair or any object carrying corner_lo / corner_hi (DesignRegion, TopologyDesignRegion). Corners resolve to cells the way topology_optimize resolves them — grid.position_to_index on each corner, both ends INCLUSIVE — and the realized cell counts, not the declared extent, are what design_eps_override must match. One difference: a corner outside the interior is CLAMPED by topology_optimize and RAISES here, so a region that reaches into the absorber is a smaller design region there and an error on this path.

What it buys: the Yee E update is linear in the fields, so the
backward pass needs the primal field only where a coefficient is
a design variable. With the grid-wide coefficients left constant
and only this box recomputed from the traced permittivity,
reverse-mode AD stores box-shaped arrays per timestep where
``eps_override`` stores grid-shaped ones. The gradient is the
same gradient (measured to 2.5e-14 relative in float64 against
``eps_override`` on a 57x57x41 open-domain fixture); this is a
change of formulation, not an approximation, and it needs
neither ``checkpoint_segments`` nor any recompute.

**Pass ``checkpoint=False`` with it.** ``checkpoint=True`` (this
method's default) remats every step, which saves the scan CARRY
— the six grid-sized field arrays — once per step no matter what
the coefficients are. Measured on a 23x21x19 grid over 80 steps
with a 3x3x3 box: 0.59 MB of residuals at ``checkpoint=False``
against 26.2 MB at ``checkpoint=True``. The box removes the
coefficient term; only turning the per-step remat off removes
the carry term with it.

The GRADED MESH carries it too (#1183): the corners resolve
against the non-uniform grid's own cell edges and the box redo
uses the graded curl, so a thin substrate meshed with a
``dz_profile`` gets the same box-shaped tape. On that lane a
waveguide port, an MSL port and ``until_decay`` raise — the
first two write their own field or ``materials`` across a whole
plane from the background permittivity.

Fenced, not degraded — for a design PERMITTIVITY
(*design_eps_override*): ``distributed=True``,
``solver='adi'``, ``boundary='upml'``, Debye/Lorentz
dispersion, Kerr, subpixel/anisotropic permittivity,
``pec_occupancy_override``, ``stencil_order=4``, the oblique
Bloch path, combining with ``eps_override`` / ``sigma_override``
/ ``mu_r_override``, a box that reaches into the CPML absorber,
and a box holding a source, port, lumped-RLC or
surface-impedance-sheet cell all RAISE (a lumped or wire port
may stay inside with *design_box_holds_ports*). Each of those either
computes E by some other rule inside the box or reads the
background permittivity at a box cell, and a silently dropped
design variable is a zero gradient that reads like convergence.
A design OCCUPANCY has its own, shorter list — see
*design_occupancy_override*; in particular it does combine with
``eps_override`` and with ``pec_occupancy_override``, because
it reads no material array.

design_eps_override : jnp.ndarray or None Relative permittivity at the design_box cells, shaped like the realized box. Usually the traced quantity. Required with design_box unless design_occupancy_override is given instead, and rejected without a box. design_sigma_override : jnp.ndarray, 3-tuple of them, or None Conductivity at the design_box cells, same shape. None (default) keeps the run's own conductivity there, so a lossy background inside the box is carried exactly rather than silently replaced by a lossless one. A 3-tuple (sigma_x, sigma_y, sigma_z) gives each E component its own conductivity — what a conducting SHEET needs, whose current runs along its two in-plane edges only. See ~rfx.simulation.DesignBoxSpec. design_occupancy_override : jnp.ndarray or None Issue #1183. Relaxed-conductor occupancy in [0, 1] at the design_box cells — metal-shape design (a notch stub, topology optimisation) rather than a dielectric one. It REPLACES pec_occupancy_override at those cells and carries it everywhere else, and the gradient is the same gradient pec_occupancy_override gives with the same values written into it.

What it buys is the same trade: ``apply_pec_occupancy`` scales
every E component by ``1 - M``, so a traced whole-grid occupancy
makes the backward pass keep three GRID-sized primal E arrays
per timestep. Confined to a box, the arrays it keeps cover the
box plus one cell — the reach of the incident-cell rule — and
``1 - M`` itself is built once outside the time loop, because an
occupancy is a design variable and not a field. Pass
``checkpoint=False`` with it, for the reason above.

Uniform single-device Yee lane only; a graded mesh,
``distributed=True`` and ``solver='adi'`` raise. So do a
periodic axis (the incident rule wraps across the seam, so the
box's window is no longer the whole set of cells its occupancy
can move), combining it with *design_eps_override* in one call,
a box covering a cell the port setup CLEARS the occupancy at
(the port cell, its six face neighbours, an MSL port's in-plane
diagonal owners), and the Kottke occupancy lane
(``RFX_PEC_OCC_KOTTKE=1``), where the occupancy becomes an
inverse-eps tensor inside the E update that the box's values
never reach.

design_box_holds_ports : bool Let a permittivity design_box contain lumped and wire ports (default False: a box holding a port raises). A port is a device on its edges — a 50 ohm load and a drive built from the drawn materials before the time loop — so each edge a port drives or loads inside the box's update window keeps the coefficients the drawn materials give it, for the update and the drive alike: the design values do not reach it, their derivative through it is exactly zero, and a box holding a port with the background written into it gives the run without the box. Holding keeps the DRAWN material in the port's feed gap, so the modelled structure differs from the same design written as a whole-grid eps_override by that gap. Passive ports (excite=False) are held too. The other edges of the port's cells stay design variables; at a port's cell a design value is the cell's volume material. The held edges come back as ForwardResult.design_box_held_edges, (axis, i, j, k) with axis 0, 1, 2 for Ex, Ey, Ez. A soft source (even on a port's edge), a magnetic source and a lumped RLC element in the box still raise; an MSL port's termination is not held. pec_mask_override : jnp.ndarray or None Additional hard PEC mask to merge with geometry-defined PEC. pec_occupancy_override : jnp.ndarray or None Relaxed conductor occupancy field in [0, 1] for differentiable PEC-style optimisation. n_steps : int or None Number of timesteps. If None, auto-computed from num_periods. num_periods : float Number of periods at freq_max for auto step count. checkpoint : bool Enable gradient checkpointing (default True). checkpoint_segments : int or None If set, route the uniform single-device forward through the segmented-checkpoint path (issue #73), which trades ≈2x compute for ≈sqrt(n_steps) reverse-mode memory. Must divide n_steps exactly (padding is rejected so DFT accumulator windows do not shift). None (default) keeps the legacy per-step jax.checkpoint behaviour. Currently wired only on the uniform single-device path; non-uniform meshes and distributed=True raise NotImplementedError (use checkpoint_every on the non-uniform path instead). emit_time_series : bool Emit the probe time series in the returned ForwardResult (default True). emit_time_series=False skips the time-series buffers and is currently only supported on the non-uniform forward path; on the uniform path it raises NotImplementedError (frequency-domain objectives such as NTFF and S-params still emit the series there). checkpoint_every : int or None Non-uniform-mesh counterpart of checkpoint_segments (a chunk size, not a segment count; issue #73). When set to a positive integer K, the non-uniform scan is run as a scan-of-scan that remats every K steps for ≈sqrt(n_steps) memory. Currently only supported on the non-uniform forward path; on the uniform path it raises NotImplementedError (use checkpoint_segments there). None (default) leaves forward-only runs unchanged. n_warmup : int Number of leading timesteps to run with the carry stop_gradient'd so reverse-mode AD builds no tape for the initial transient (issue #40). Only the trailing n_steps - n_warmup steps participate in autodiff. Must satisfy n_warmup < n_steps. Default 0 (all steps differentiated).

The truncation error DEPENDS ON DISTANCE FROM THE SOURCE TO THE
DESIGN REGION — it is not simply "an approximation" that always
costs accuracy (an earlier version of this docstring implied the
error applies unconditionally; that was an overgeneralization
from a single, worst-case fixture — see below). Severing the
carry severs every gradient path through steps ``< n_warmup``,
but only steps during which the WAVEFRONT HAS ALREADY REACHED
the design region carry any such path to sever; before the
wavefront arrives the field there is ~0, so severing those
steps discards ~nothing and the gradient is (near-)exact.
Compute a safe bound yourself::

    K_safe ~= floor(min_distance(source, design_region) / (C0 * dt))

(``min_distance`` is the minimum over EVERY active source AND
over each source's own spatial extent, not just its nominal
position — a TFSF plane-wave source illuminates from an
entire box face, not a point; grid steps for the wavefront to
reach the closest design cell, ``C0`` = vacuum lightspeed,
``dt`` = ``sim._build_grid().dt`` /
``sim._build_nonuniform_grid().dt`` — a conservative floor valid
for any slower propagation). ``n_warmup <= K_safe`` stays
within this repo's ~1.5% AD-vs-FD noise floor (near-exact,
<0.1%, well below ``K_safe``); well beyond ``K_safe``, error
grows further and can reach the full gradient magnitude by
the time ``n_warmup`` reaches the loss window. Two measured
regimes (issue #626 part 2 / addendum, both vs an independent
central-FD oracle): a NEAR-SOURCE placement (design cell ~3
cells from the source, so ``K_safe ~ 0``) shows error growing
smoothly from a ~1.5% noise floor up to 58% at the
loss-window boundary itself, and EXACTLY zero (gradient fully
vanished) once ``n_warmup`` extends far enough into the loss
window (``tests/unit/autodiff/test_n_warmup.py::
test_warmup_truncation_error_grows_with_k``); a
FAR-FROM-SOURCE placement (design cell 62 cells from the
source, ``K_safe=108`` measured from the grid's own ``dt``)
shows AD matching the ``n_warmup=0`` FD oracle to <0.01%
through ``K_safe``-20, then growing SMOOTHLY (not a sharp
cliff) as ``n_warmup`` approaches ``K_safe`` -- 0.26% at
``K_safe``-4, 0.60% at ``K_safe``-2, 1.19% AT ``K_safe``
itself (still inside the noise floor above) -- continuing to
grow past it toward 75% by ``K_safe`` + 92 —
``scripts/diagnostics/i626_n_warmup_wavefront_locality.py``
(finely sampled around ``K_safe``; an earlier version of this
docstring, read from a coarser sweep that skipped every point
near the boundary, overstated the transition as a sharp
cliff "exactly at K_safe"). The near-source curve above is
the WORST case, not the general
case: use ``K_safe`` to decide whether YOUR source/design
placement is in the exact or the truncated regime before
assuming ``n_warmup`` costs accuracy. Forward (non-AD) output
is exactly ``n_warmup``-invariant (bit-identical) in every
placement measured; only the gradient is affected. Currently
only supported on the non-uniform / distributed-non-uniform
forward paths; on the uniform single-device lane it raises
``NotImplementedError`` (issue #626 — previously silently
accepted and ignored, worse than an error).  Use
*checkpoint_segments* for uniform-lane reverse-mode memory
relief instead — it is exact regardless of placement, at the
cost of recompute rather than gradient accuracy.

skip_preflight : bool Skip the consolidated preflight() validation suite that normally runs before the forward simulation (default False). Use only when preflight has already been run by the caller (e.g. rfx.optimize runs it once at entry) or to bypass a known-spurious warning on a deliberate configuration. distributed : bool, optional Opt-in, unstable, pending GPU evidence (issue #44). When True and a non-uniform mesh is configured, route the differentiable forward through the sharded NU runner (rfx.runners.distributed_nu.run_nonuniform_distributed_pec) using a 1-D x-slab decomposition across devices. Defaults to False (single-device path, no behaviour change). In v1.6.2 the distributed forward path is NU-only (DP3): a uniform mesh raises NotImplementedError. TFSF sources and waveguide ports are unsupported on this path and raise NotImplementedError at preflight. devices : list of jax.Device or None, optional Devices for the distributed run. When distributed=True and devices=None, defaults to jax.devices(). When an explicit list is supplied, every device must already exist in jax.devices() (otherwise ValueError). Passing devices= without distributed=True raises ValueError — there is no silent activation of the distributed lane. exchange_interval : int, optional Ghost-cell exchange interval for the distributed runner (default 1). Only 1 is currently supported by run_nonuniform_distributed_pec; other values are forward-compatible reservations and will raise inside the runner. port_s11_freqs : array-like or None Frequencies (Hz) at which to accumulate per-port V/I DFTs inside the JIT scan body so that ForwardResult.s_params is populated with wave-decomposition |S11| values for lumped/wire ports (issue #72) — the AD-traceable counterpart of run(compute_s_params=True). Required by the minimize_s11_at_freq_wave_decomp() objective. Currently supported on uniform and graded single-device paths; distributed=True raises NotImplementedError. rlc_values_override : dict or None Differentiable-value injection for lumped RLC elements added with add_lumped_rlc() (WP 4-E). LumpedRLCSpec stores plain floats, so a component value can only enter the AD tape as a tracer through this override. Maps a 0-based element index (registration order over add_lumped_rlc() calls) to a dict with any of the keys "R" / "L" / "C" whose values replace the registered floats — e.g. forward(rlc_values_override={0: {"R": R, "C": C}}, ...) makes R and C of the first RLC element differentiable. A missing element index or key falls back to the registered spec float. None (default) uses the registered floats for every element. Only wired on the uniform single-device path. NOTE: on this path a registered lumped RLC element now correctly affects forward() even without an override — previously it was a silent no-op. Uniform-path only; the non-uniform / distributed lanes do not iterate self._lumped_rlc in forward(). ringdown : rfx.ringdown.RingdownSpec or None Issue #1254. Complete the wire-port S-parameters of a record cut while the structure still rings, inside the differentiable program: ForwardResult.ringdown.s_params is the completion run(ringdown=...) gives of the same record (the poles of the port voltage and current identified on [window_start T, T], the unrecorded tail of each spectrum added in closed form), and jax.grad through it differentiates the infinite-record spectrum (the poles by the frozen Gauss-Newton implicit derivative, the residues re-fitted), not the cut record. .s_params_long is the completion from the window that starts twice as early; the gradient through it, against the gradient through .s_params, is the gradient's witness (rfx.ringdown.gradient_witness()). .report holds the checks of the value (W0, W1, WE and the rest, as run()'s), computed on the host from a concrete result and None while traced. Wire ports only (add_port(..., extent=...)); on the uniform lane one port and port_s11_freqs= are needed (the bins), on the graded lane the completion uses the lane's own bins. Everything run(ringdown=...) refuses is refused, and so are distributed=True and emit_time_series=False. None (default) leaves every output and the traced program as they were.

**_removed_kwargs Rejection shim for removed keywords, providing migration errors; it does not accept additional simulation options.

Returns

ForwardResult Minimal differentiable observables (time series and optional NTFF). settling_db and settling_witness score retained user-probe records on the concrete result using the run() diagnostic and underflow floor. They are host diagnostics, unavailable during tracing, and can be read on concrete records outside the jitted objective. Other result fields keep their existing JIT restrictions, including the host Grid object. Absent records yield None rather than a passing value. Use settling_verdict(result.settling_db) for the shared -40 dB bar.

Notes

forward() refuses refinement: it has no subgridded solve. forward() can be called inside jax.jit, so an optimisation step compiles once: step = jax.jit(jax.value_and_grad(loss)) compiles on its first call and reuses the program afterwards, while an un-jitted jax.value_and_grad(loss) compiles the whole solve again on every call (#1225). Under an outer jax.jit (single device), the set-up built from the model alone is evaluated while tracing and the time stepping is compiled into the caller's program (#1367, #1354), so a function with no traced input compiles too. The set-up's arrays become constants of that program: the first call costs a plain call's set-up on top of the compile, and the compiled program holds those arrays for as long as JAX caches it. The loop compiles over operands XLA cannot read, as in a plain call; what is computed from a traced input before the loop (a port's drive from a traced permittivity, #1320) is compiled with the caller's program, so the jitted value and gradient agree with a plain call to float32 rounding, not necessarily bit for bit.

def run( self, *, n_steps: int | None = None, num_periods: float = 20.0, until_decay: float | None = None, decay_check_interval: int = 50, decay_min_steps: int = 100, decay_max_steps: int = 50000, decay_energy_consecutive: int = 2, radiated_flux_box: tuple | None = None, flux_env_checks: int = 4, decay_monitor_component: str = 'ez', decay_monitor_position: tuple[float, float, float] | None = None, checkpoint: bool = False, compute_s_params: bool | None = None, s_param_freqs: jax.jaxlib._jax.Array | None = None, s_param_n_steps: int | None = None, snapshot: SnapshotSpec | None = None, subpixel_smoothing: bool | str = False, conformal_pec: bool | None = None, conformal_min_weight: float = 0.1, devices: list | None = None, exchange_interval: int = 1, skip_preflight: bool = False, report_every: int | None = None, report_label: str = '', ringdown: rfx.ringdown.RingdownSpec | None = None, until_identified: bool = False) -> Result:

Run the simulation.

Parameters

n_steps : int or None Number of timesteps. If None, auto-computed from num_periods. num_periods : float Number of periods at freq_max for auto timestep count. checkpoint : bool Enable gradient checkpointing for reverse-mode AD. compute_s_params : bool or None Compute S-parameter matrix. Default: True when ports exist. s_param_freqs : array or None Frequencies for S-parameters. Default: 50 points from freq_max/10 to freq_max. s_param_n_steps : int or None Timesteps for S-parameter simulation (may need more than the main simulation for frequency resolution). until_decay : float or None When provided, overrides n_steps and runs until field energy decays to this fraction of peak. E.g. 1e-3. Boundary-dependent stop (issue #169, RESOLVED for absorbing boundaries): On absorbing boundaries (cpml / upml) the stop now uses the total interior-domain energy U = sum(E^2 + H^2) over the non-CPML interior — a whole-domain quantity that does not pass through the per-cell interference nulls of the old single-cell point-field stop, so it is suitable for flux / S-parameter / transmission measurements on guided / low-loss geometries (#169 resolved). On closed / PEC boundaries domain energy does not decay, so the stop falls back to the historical instantaneous single-cell field; that fallback keeps the original limitation (valid only for lossy / radiating ring-down, not flux / S-param / transmission gating on guided / low-loss closed geometries — use a fixed n_steps there, see the rfx.simulation.run_until_decay() note). Non-uniform (dx/dy/dz-profile) meshes (issue #383): the same interior-energy stop runs on absorbing (cpml/upml) boundaries via a chunked host loop over the NU scan step (dV-weighted energy — per-cell dx*dy*dz — so graded cells are weighted faithfully). Closed-boundary NU sims refuse until_decay (no point-field fallback on the NU lane). On the NU decay path all step-sized buffers are allocated at decay_max_steps and flux monitors must keep the default rectangular DFT window. (The non-uniform runner additionally rejects its internal checkpoint_every segmentation option when a decay stop is requested; that option belongs to the forward() lane and is not reachable through run().) DC floor (issue #388): a soft-source waveform with net DC content deposits a static end-charge whose self-energy floors the interior-energy criterion; run() warns quantitatively when a planned soft-source waveform's relative DC exceeds 1e-3 (raise the GaussianPulse cutoff, narrow a ModulatedGaussian to bandwidth <= 0.4, or use a fixed n_steps). This advisory does NOT catch low-rel_DC floors amplified by thin source cells (the #388 patch class measured the floor at rel_DC ~6e-5) — see issue #388. decay_check_interval : int Check decay every N steps (default 50). decay_min_steps : int Always run at least this many steps (default 100). decay_max_steps : int Hard upper limit on steps (default 50000). decay_energy_consecutive : int Absorbing-boundary only: number of consecutive sub-threshold interior-energy checks required before stopping (default 2; >= 2 mandatory — the interior energy is not null-free and a single check can false-fire on a transient inter-packet dip). radiated_flux_box : tuple or None Physical lower/upper corners of a closed box enclosing the radiator, clear of CPML. Selects outgoing-flux decay instead of interior-energy decay on absorbing boundaries; None keeps the energy criterion. flux_env_checks : int Number of recent checks whose maximum absolute flux forms the radiated-flux envelope (default 4). snapshot : SnapshotSpec or None Field snapshot schedule and selection; None disables snapshots. subpixel_smoothing : bool or str Material-interface smoothing rule; False disables smoothing. True enables dielectric smoothing; "kottke_pec" selects the unified PEC occupancy rule on supported lanes. skip_preflight : bool Skip advisory preflight checks. Runtime admission guards still apply. decay_monitor_component : str Field component to monitor (default "ez"). Used only by the closed/PEC point-field fallback stop. decay_monitor_position : tuple or None Physical position to monitor. If None, use domain center. conformal_pec : bool Enable Dey-Mittra conformal PEC for second-order accuracy on curved PEC surfaces. Default False (staircase PEC). conformal_min_weight : float Minimum conformal weight for CFL stability clamping. Default 0.1. Recommended range: 0.05-0.3. devices : list of jax.Device or None When a list with len > 1 is provided, run the simulation distributed across those devices using 1D slab decomposition along the x-axis via shard_map. Supported combinations are listed in docs/guides/support_matrix.md. exchange_interval : int, optional Ghost exchange interval in timesteps; only integer 1 is supported. report_every : int or None Issue #667 — progress reporting for long solves. When set, print one [PROGRESS] ... line every N timesteps giving steps done / total, wall elapsed, implied rate and ETA, on the same stdout channel as preflight so a redirected job log captures it. None (default) is OFF and leaves the solve on its unchanged code path, so nothing moves for existing callers.

Supported on the **uniform lane only** (both the fixed
``n_steps`` scan and ``until_decay``); the distributed,
non-uniform, ADI and subgridded lanes warn and drop it rather
than silently producing no output. It is forward-only and
raises under ``jax.jit``/``grad``/``vmap`` (the check inspects
the carry, the per-step inputs and the material / geometry
arrays — the routes ``forward()``/``optimize()`` trace through).

On the fixed-``n_steps`` scan the reporting turns the single
compiled scan into ``ceil(n_steps / report_every)`` calls to
the SAME compiled scan with ``carry`` threaded through — a
continuation, not a re-solve, and bit-exact against
``report_every=None`` (locked by
``tests/unit/runners/test_run_progress_reporting.py``).

Each report costs one device synchronisation plus one print.
On a 118k-cell CPU fixture that cost was **not resolvable above
the measurement noise** at any cadence tried: a paired
off-vs-off self-control read -4.8% [-9.3, -0.9], and every
cadence from 2000 down to 25 landed inside that floor. The one
clean figure is +0.7-0.8% for a single-chunk run, bounding the
fixed per-report cost. So choose ``report_every`` for a useful
reporting interval, not to minimise an overhead this benchmark
could not detect; only the direction (finer cadence costs more)
is established. None of this speaks to the large-grid GPU
regime, which was not measured.

report_label : str Short tag prefixed to each progress line (e.g. "drive p1"). Ignored when report_every is None. ringdown : rfx.ringdown.RingdownSpec or None Issue #1254 — complete the wire-port S-parameters of a record cut while the structure still rings. The slow poles are identified on [window_start * T, T] of the port voltage and current (the source must be off there) and the unrecorded tail of each spectrum is added in closed form. The result is Result.ringdown (.s_params on the bins of Result.s_params, .report with the window, the poles and the witnesses: WE when available (otherwise W2), no growing pole, passivity, source off). Every other output is the one the same run gives without ringdown=. Wire ports (add_port(..., extent=...)) on the uniform (one port) and graded-mesh lanes, fixed record length; other ports, TFSF, Kerr, devices= and until_decay are refused with the reason. until_identified : bool Issue #1254 — with ringdown=, end the run once the completion is witnessed instead of at n_steps, which becomes the longest record allowed. The record is checked on a geometric schedule (every check at least 1.25x the record of the last); the run stops at the first check where every source is off over [T/4, T], the error witness WE is within witness_tol there and at the check before, the record is at least half the amplitude decay time Q / (pi f) of the slowest mode whose own unrecorded tail moves the completed S by witness_tol (a static field left in a closed box is left out; the largest such decay time over the checks the two-in-a-row rule rests on), and the completion reads passive with no growing pole. The result is the one run(n_steps=T, ringdown=...) returns for the stopping record T, with the stop report (every check's record, WE, the pole that set the floor, the conditions and the check's wall time) in Result.ringdown.stop. Each check identifies the record twice (0.1-3 s on a CPU for the test cavity); on a small model that costs more than the steps it saves. A weakly coupled high-Q mode holds the floor for half its own decay time, which can be longer than n_steps. When no check stops the run, the whole n_steps record is completed and the report names the condition that failed. Refused without ringdown=, with until_decay, snapshot=, DFT planes and flux monitors.

Returns

Result

def export_scene(self, path: str | pathlib.Path | None = None, **kwargs: Any):

Return or write the native runtime scene artifact.

path=None returns a JSON-serializable dict. Otherwise the scene is written as indented JSON and the output ~pathlib.Path is returned.

def artifact_report(self, result=None, **kwargs: Any):

Return a runtime artifact report dict for this simulation.

def export_artifact_bundle(self, path: str | pathlib.Path, result=None, **kwargs: Any):

Write a runtime artifact bundle and return ArtifactBundle.

def estimate_ad_memory( self, n_steps: int, *, available_memory_gb: float | None = None, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, n_warmup: int = 0) -> AD_MemoryEstimate:

Estimate reverse-mode AD memory for this simulation.

Returns a static-estimate AD_MemoryEstimate with forward, checkpointed-AD, and non-checkpointed-AD sizes in GB, plus a best-effort warning if estimated AD memory exceeds 85% of available VRAM. This artifact is planning evidence, not a certificate of peak runtime memory.

When checkpoint_every is provided, the returned ad_segmented_gb reflects the non-uniform segmented scan-of-scan chunk-size path from issue #31. When checkpoint_segments is provided, it reflects the uniform segmented-scan segment-count path from issue #73.

The segmented model (issue #277) counts BOTH terms of the rematerialized backward pass:

  • segment-boundary storage — carry + cotangent per active segment, 2 * active_segments * field_bytes;
  • the live-segment rematerialization tape — the backward pass replays one segment at a time, so that segment's per-step field tape (n_steps // checkpoint_segments steps on the uniform path, checkpoint_every steps on the chunked path) is resident on top of the boundary storage. The non-uniform runner pads the trailing chunk with zero source to a full chunk, so the full chunk length is the correct live-tape size; both live terms are capped at the active (post-warmup) step count.

The segmented estimate is therefore minimized when the two terms balance, near sqrt(2 * n_steps) steps per segment — NOT by pushing the segment count to either extreme. Use plan_ad_memory() to pick a balanced knob for a budget. n_warmup must be an integer with 0 <= n_warmup < n_steps and reduces reported reverse-mode tape time. The legacy ad_checkpointed_gb field keeps its old (optimistic) heuristic for backwards compatibility; it is NOT accurate for FDTD on the non-uniform path — see the class docstring.

def explain_ad_memory( self, n_steps: int, *, available_memory_gb: float | None = None, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, n_warmup: int = 0) -> ADMemoryExplainabilityReport:

Explain the static reverse-mode AD memory estimate.

This method uses the same conservative accounting as estimate_ad_memory(), then decomposes the selected AD memory number into named contributors. It is meant to answer "what is making this AD run large?" without claiming profiler evidence or a runtime peak bound.

def plan_ad_memory( self, n_steps: int, available_memory_gb: float, *, target_fraction: float = 0.85, n_warmup: int = 0, safety_factor: float = 1.3) -> ADMemoryPlan:

Choose a segmented-AD memory plan for a memory budget.

The planner reuses estimate_ad_memory() and returns a calibrated conservative planning artifact rather than mutating the simulation or certifying runtime peak memory. If ordinary reverse-mode AD already fits under target_fraction * available_memory_gb, both checkpoint knobs are None. Otherwise it returns a segmented candidate: checkpoint_every for non-uniform grids or checkpoint_segments for uniform grids. Only wire the candidate when segmented_fits is true; a non-fitting plan keeps the least-memory candidate as diagnostics. n_warmup must be an integer with 0 <= n_warmup < n_steps and reduces active reverse-mode time. Raw estimates must also fit after multiplying by safety_factor before fit flags are set, so near-boundary plans stay conservative.

def ad_memory_preflight( self, n_steps: int, available_memory_gb: float, *, target_fraction: float = 0.85, n_warmup: int = 0, safety_factor: float = 1.3, include_mesh_report: bool = True, check_ntff: bool = True, check_resolution: bool = True, residual_fun: Callable[..., typing.Any] | None = None, residual_args: tuple[typing.Any, ...] = (), residual_kwargs: Mapping[str, typing.Any] | None = None, residual_top_n: int = 10, residual_workflow: str | None = None, residual_context: Mapping[str, object] | None = None) -> ADMemoryPreflightReport:

Return a one-call AD-memory preflight planning artifact.

The report composes existing static memory planning, static explainability, optional mesh advisories, and optional trace-time JAX saved-residual diagnostics. It does not run FDTD and does not upgrade static planning evidence into runtime memory proof.

def ad_memory_compiled_certificate( self, compiled: object, *, n_steps: int, available_memory_gb: float, target_fraction: float = 0.85, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, n_warmup: int = 0, precision: str | None = None, input_signature: object | None = None, static_signature: object | None = None, compiled_object_id: str | None = None, runner_or_objective: str | None = None, preflight: ADMemoryPreflightReport | None = None, scope_context: Mapping[str, object] | None = None) -> ADCompiledMemoryCertificate:

Return a fail-closed compiler-memory certificate for one executable.

This method accepts an already-compiled JAX object and reads only its memory_analysis() output. It does not compile callables, run FDTD, profile runtime memory, or convert static memory plans into guarantees. Green statuses are bounded to the supplied compiled object and complete exact-scope metadata.

def compute_waveguide_s_matrix( self, *, n_steps: int | None = None, num_periods: float = 20.0, normalize: bool | str = False, subpixel_smoothing: bool | str = False, eps_override: jax.jaxlib._jax.Array | None = None, sigma_override: jax.jaxlib._jax.Array | None = None, checkpoint_segments: int | None = None, strict_passivity: bool = False, port_reference_sims: list[Simulation] | None = None) -> WaveguideSMatrixResult:

Compute a theoretically clean axis-normal boundary-aperture waveguide S-matrix.

Requires solver="yee". On a uniform mesh, precision selects field storage for every device and reference run, as on run(). A non-uniform mesh supports only precision="float32".

Parameters

num_periods : float Length of the FDTD run (in source-period multiples) used to derive n_steps when n_steps is not given. The spectra are computed POST-SCAN from the recorded modal V/I time series via a rectangular full-record DFT (matching OpenEMS's utilities.DFT_time2freq); num_periods therefore governs both the CPML drain horizon AND the DFT integration window. Phase 2 cleanup (2026-04-25) removed the legacy num_periods_dft early-gate knob — the rect full-record DFT is finite-energy on the recorded transient so no gating is needed even on strong reflectors. normalize : bool or "flux" Controls the S-parameter extraction algorithm:

``False`` (default) — modal V/I decomposition, no reference
run.  Magnitude includes Yee impedance mismatch
(Z_TE_num/Z_TE_exact ≈ 3 % at dx/λ = 0.07).  Use for
|S11| of strong reflectors (PEC short, high-Q resonators)
where this error is smaller than the ±10–20 % round-trip
dispersion error introduced by ``normalize=True``.  On the
differentiable chain: a traced ``eps_override`` /
``sigma_override`` flows through it.

``True`` — two-run modal normalization.  Cancels one-way
Yee dispersion for **transmission** (off-diagonal) by
dividing device outgoing waves by reference outgoing waves
at the same port.  **Does not** cancel dispersion for
reflection (round-trip vs one-way path mismatch); use
``normalize=False`` or ``normalize="flux"`` for S11 of
strong reflectors.  Host-assembled and **outside the
differentiable chain**: a traced ``eps_override`` /
``sigma_override`` (``jax.grad``, ``jax.jit``) raises
``NotImplementedError`` at this call, before any FDTD run;
a concrete override still runs forward.

``"flux"`` — hybrid power-flux extraction.  Magnitude from
Poynting-vector DFT (|S|² = P_flux / P_inc), phase from
modal V/I.  Corrects both the Z_TE impedance-mismatch error
in S11 and the round-trip dispersion error in the
``normalize=True`` diagonal formula.  Costs 2 × N_ports
FDTD runs (same as ``normalize=True``).  On the
differentiable chain like ``False``. On the uniform lane the
result promotes field, frequency, and JAX default float precision,
like ``False``: complex64 with x64 off and at least complex128 with
x64 on, including explicit float32 frequencies and float32/mixed fields.
Modal records retain at least the frequency and default float precision.
The non-uniform lane's result follows the ``freqs`` precision:
complex64 by default, complex128 under ``JAX_ENABLE_X64``.

**Reference impedance.**  On ``False`` and ``True`` each
S_ij is the modal voltage-wave ratio ``b_i / a_j``, where
the waves at a port are decomposed with that port's OWN
discrete modal impedance (Yee-discrete Z_TE or Z_TM from
the port's cutoff and cell size); no ``sqrt(Z_i / Z_j)``
renormalization is applied.  ``"flux"`` takes |S_ij| from
the power ratio and its phase from the same modal waves.
The returned matrix therefore equals a power-wave S only
when every port shares one cross-section and mode — the
validated scope (straight guides and same-guide junctions).
Ports with dissimilar cross-sections are outside it.

**Multimode.**  ``n_modes > 1`` on any port is assembled on
the host for every ``normalize`` value and is outside the
differentiable chain; ``eps_override`` / ``sigma_override``
differentiation is single-mode only.

checkpoint_segments : int or None Segmented gradient checkpointing for the uniform waveguide AD path (issue #73 / PR #125). Splits the n_steps scan into K segments that are rematerialised via jax.checkpoint during the backward pass, reducing peak reverse-mode memory from O(n_steps·|carry|) to O((K + n_steps/K)·|carry|) (≈ O(√n_steps·|carry|) at the optimal K ≈ √n_steps, at ≈ 2× backward compute cost). K is forwarded to rfx.simulation.run with checkpoint=True; K MUST exactly divide the auto-computed n_steps (the runner rejects non-divisors — choose the nearest divisor of √n_steps; padding is rejected because it would shift the V/I DFT windows). Default None is byte-identical to the pre-checkpoint scan.

On a NON-uniform mesh (``dx_profile`` / ``dy_profile`` /
``dz_profile``, issue #73) ``checkpoint_segments=K`` is now supported: ``K`` is
translated to the NU runner's ``checkpoint_every`` chunk size — the
divisor of ``n_steps`` nearest to ``n_steps/K`` — and applied to the
*device* run only (the vacuum reference is constant in the design
variable). The chunk MUST divide ``n_steps`` (same as the uniform
path): a non-divisor chunk would let the NU runner's zero-padding add
spurious ring-down steps to the carry-accumulated flux DFT and shift
the S-matrix. With an exact divisor the result is forward-IDENTICAL
and the ≈O(√n_steps) tape reduction is realised under ``jax.grad``
with ``normalize='flux'`` + an ``eps_override`` / ``sigma_override``
design variable.

port_reference_sims : list[Simulation] or None Per-driven-port matched-straight-guide reference simulations for interior-PEC multi-port structures (T-junctions, branches, septa). port_reference_sims[i] is a Simulation describing the STRAIGHT continuation of driven port i's guide with no junction — same domain / dx / boundary, geometry = port i's guide walls extended straight through. Only valid with normalize='flux' (raises otherwise); single-mode ports only; uniform mesh only; not combinable with eps_override / sigma_override.

**Multi-port junctions (port_reference_sims).** The default flux
path references incident power ``P_inc`` to a single shared VACUUM
run (empty domain, no interior PEC). For a straight guide the walls
come from ``pec_axes`` so that vacuum reference already carries the
guided ``P_inc`` correctly. For a junction the interior PEC septum /
branch is stripped from the vacuum reference, which then radiates
into free space: ``P_inc`` is mis-normalized and every ``|S|``
inflates hard (non-passive; ``normalize=True`` gave max|S|~230,
``normalize='flux'`` gave max|S|~9.8, |S11|~1.9 on the verified
compact 3-port T-junction). Passing ``port_reference_sims[i]`` = the
matched straight guide for driven arm ``i`` moves ``P_inc`` toward
the true single-mode guided incident power (PEC-folded identically
to the device run).

**Far-port discipline (required for a physical S-matrix).** This
plumbing is NECESSARY but NOT SUFFICIENT. A physical junction
S-matrix additionally requires: (1) each port's probe plane placed
>= 5 evanescent decay lengths of the next higher mode away from the
junction; (2) CPML thickness >= ~0.5 guide wavelengths at band
centre; (3) a converged mesh. On far-port geometry (arms 90/90/70 mm,
48 mm CPML, dx 1.0/0.667 mm) the matched-reference flux path reaches
passivity 1.006/1.002, reciprocity 0.001, mesh-convergence 0.0297
and 0.087 vs MEEP (r2000, cross-device). On COMPACT geometry the
matched reference fixes |S11| (1.86 → 0.49, physical) but the overall
matrix stays non-physical (residual max|S|~3.9); the two in-method
advisories (probe clearance, CPML thickness) warn when the far-port
discipline is not met.

The absorber half of that discipline is checked on **every**
uniform path, not just this one: an in-method advisory (issue
#494) fires whenever the absorber on a port's propagation axis is
thinner than ``0.5 * lambda_g`` at the **lowest** measured
frequency, which is where ``lambda_g`` is longest and the
``cpml_layers=16`` default weakest. It is checked in-method
because the functional entry points run no ``sim.preflight()``.
Treat ``0.5 * lambda_g`` as a floor, not a target: at the WR-90
band edge the measured residual ``|S11|`` ripple was 0.0706 at
0.30 ``lambda_g``, 0.0366 at 0.50 and 0.0093 at 0.75, so a
0.5-``lambda_g`` absorber can still set the accuracy envelope
instead of discretization. The advisory is silent on a
non-uniform mesh (``cpml_layers * dx`` is ambiguous under a
graded profile) and on a band that starts at or below cutoff
(``lambda_g`` is undefined there; the ``port_freqs_below_cutoff``
preflight owns that case).

This enables junction measurements UNDER the documented
discipline; it does NOT make arbitrary compact junctions
valid. Consult ``docs/guides/sparameter_support_matrix.md`` for
the current waveguide evidence and scope.
def compute_mixed_s_matrix( self, *, n_steps: int | None = None, num_periods: float = 40.0, freqs: jax.jaxlib._jax.Array | None = None, n_freqs: int = 100, strict_extractor: bool = False, enforce_passivity: bool = True, skip_preflight: bool = False, return_diagnostics: bool = False, magnitude_channel: str = 'flux', reciprocity_tol: float = 0.06) -> MixedSMatrixResult:

Mixed-family S-matrix: lumped/wire ports + MSL ports (issue #488).

End-to-end S-parameters on ONE structure carrying two port families — the first supported pair is a homogeneous lumped OR wire set (add_port) together with MSL ports (add_msl_port), e.g. a vertical probe feed launching onto a microstrip line.

Each port is driven in turn (lumped/wire ports first, then MSL ports, registration order within each family); the non-driven lumped/wire ports remain physical matched resistor loads and the non-driven MSL ports are passive probe columns. Extraction reuses the validated per-family wave machinery unchanged and combines the waves in the Kurokawa power-wave convention (each wave divided by sqrt(Re Z0) of its own port) — with unequal reference impedances across families a pseudo-wave ratio would be off by sqrt(Z_j/Z_i) (issue #460); reciprocity of a reciprocal structure is the committed internal falsifier for this choice.

MAGNITUDE CHANNEL (magnitude_channel, default "flux"): the #488 falsifier battery + an independent Poynting-flux referee measured the port-cell V*I accounting undercounting delivered power ~3x at a wire probe feed (the OPEN issue-#313 class) and the analytic Hammerstad-Jensen Z0 anchor diverging from the measured line ratio on an interface-aligned mesh. Off-diagonal MAGNITUDES are therefore taken from Poynting flux by default: auto-registered surfaces (a closed 5-face box around each lumped/wire port over the z-lo ground; a full cross-section plane at each MSL port's probe-0 x) give per-drive net powers, and

|S_ij|^2 = P_arrive,i / (P_net,j / (1 - |S_jj|^2))

where S_jj is the per-family diagonal supplying the reflection correction; no Z0 anchor enters the magnitude. Off-diagonal PHASE still comes from the wave channel (see below). magnitude_channel="wave" keeps the raw power-wave magnitudes (diagnostic; carries the #313 deflation at lumped/wire ports).

HONESTY NOTES (read before quoting numbers):

  • Neither diagonal is verified on this lane, and the returned diagonal is not always the measured one. Two separate findings: (a) the wire port-cell V*I accounting was measured undercounting delivered power ~3x against an independent Poynting referee (the open issue #313 reaching the diagonal at a near-field-dominated vertical probe), and on an end-fed fixture the MSL probe plane's local V/I was ~591 ohm and strongly reactive while the reported |S22| was 0.03 — those cannot both be right. (b) With enforce_passivity=True (the default), _project_passive is a JOINT SVD clip: when any entry is non-passive it rewrites others as a side effect. Measured on the committed test fixture, the shipped MSL diagonal came out ~4x its unprojected value. So result.S's diagonal is a projected quantity, not a raw per-family measurement — read S_raw and passivity_correction for what was actually measured, and treat any diagonal-derived conclusion accordingly.

  • The flux magnitudes inherit a flux-accounting envelope (box leakage + finite DFT; the Phase-0 referee class measured ~1.3% on the canonical thru) and an ill-conditioning guard: when 1 - |S_jj|^2 is small (near-total reflection at the driven port) the normalization is unreliable and a warning names the column.

  • A per-column power sum near 1 is NOT evidence on this channel. Substituting the definition gives sum_i |S_ij|^2 = |S_jj|^2 + (P_arr/P_net)(1 - |S_jj|^2), which is identically 1 whenever the arriving power equals the net launched power — for ANY value of the diagonal. The flux normalization makes passivity an identity, not a measurement, so do not quote column power as a passivity check here (it still detects power GAIN, i.e. P_arr > P_net). The independent internal check on this channel is RECIPROCITY: S_ij and S_ji come from different runs with different normalizations, so their agreement is real evidence. It runs automatically on every extraction (see reciprocity_tol), audits the RAW matrix, and is the check that a wrong diagonal trips.
  • With magnitude_channel="wave", off-diagonal magnitudes that RECEIVE at a lumped/wire port cell inherit the issue-#313 near-field deflation of the default port-cell waves. The returned s21_power_witness cross-checks the MSL-receiving direction against a delivered-power normalization; quote it alongside |S|.
  • Cross-family off-diagonal PHASE mixes two reference-plane conventions (port cell vs de-embedded MSL probe plane) and a component-mixing ±1 (probes.py sign-convention fence); magnitude is the validated observable.
  • settling_db is the ring-down witness (above −40 dB = truncation suspect); preflight output is part of the result.

v1 restrictions (loud NotImplementedError): uniform mesh only, no waveguide/Floquet/coax/TFSF registrations, no bare sources or 0-ohm ports (they would fire in every drive run), no reference_plane_cells wire ports, no mixed lumped+wire set (same fence as the production scan driver), imperative only (no eps_override AD channel). The default "flux" channel adds two more, because the per-port flux box omits its bottom face and treats the port extent as a height: a PEC z_lo boundary and vertical (component="ez") lumped/wire ports are required. magnitude_channel="wave" makes neither assumption.

Parameters

reciprocity_tol : float, default 0.06 Relative |S_ij| vs |S_ji| disagreement above which the reciprocity witness warns. Deliberately set BELOW the 9% residual measured on this lane's own reference fixture, so that fixture warns rather than passing silently: a tolerance above the known residual would document the check and never fire it.

Returns

MixedSMatrixResult

def compute_s_matrix(self, *, lane: str | None = None, **kwargs):

Compute S-parameters on whichever lane this simulation's ports select.

A single entry point over the per-family calculators. It inspects the registrations, chooses exactly one delegate, and forwards **kwargs to it UNCHANGED -- no default is invented here, so the delegate's signature, defaults, preconditions, warnings and return type are exactly what you would get calling it directly.

Dispatch table

=================================== ========================================== ================================ Registered Delegate Returns =================================== ========================================== ================================ waveguide ports only compute_waveguide_s_matrix WaveguideSMatrixResult MSL ports only compute_msl_s_matrix MSLSMatrixResult 1 coaxial port only compute_coaxial_line_reflection or CoaxialLineReflectionResult compute_coaxial_two_port -- ambiguous, or CoaxialTwoPortResult pass lane= 1 coaxial + 1 MSL compute_coax_msl_transition CoaxMSLTransitionResult lumped/wire (add_port) + MSL compute_mixed_s_matrix MixedSMatrixResult lumped/wire only NotImplementedError -- the run lane (Result.s_params) nothing ValueError -- anything else NotImplementedError -- =================================== ========================================== ================================

The lumped/wire family deliberately raises rather than calling run() for you: that lane returns a Result (not an S-matrix result type), and n_steps has no default there, so quietly substituting one would be inventing exactly the kind of default this dispatcher refuses to invent. Run it yourself::

res = sim.run(n_steps=..., compute_s_params=True)
res.s_params

Parameters

lane : str or None Explicit lane name (see s_matrix_lane()). Consumed here, never forwarded. Required for a single coaxial port, where compute_coaxial_line_reflection and compute_coaxial_two_port share one registration footprint; optional elsewhere, where it is checked against the registrations rather than overriding them. **kwargs Forwarded verbatim to the chosen delegate.

Returns

Whatever the chosen delegate returns -- see the table.

Raises

ValueError No ports registered, or lane= contradicts the registrations. Also raised by the chosen lane itself: compute_coax_msl_transition (1 coaxial + 1 MSL) refuses a non-passive extracted S by default and is the only lane in the table that does — forward strict_passivity=False through **kwargs to get the diagnostic matrix with a warning instead (issue #838). NotImplementedError Lumped/wire-only (use the run lane), a single coaxial port with no lane=, or a combination of port families no calculator covers. TypeError An unrecognised keyword argument. **kwargs swallows it here, so the message names the DELEGATE that rejected it -- Simulation.compute_waveguide_s_matrix() got an unexpected keyword argument 'foo' -- which also tells you which lane was chosen.

Notes

Preflight behaviour is inherited from the chosen lane and is not uniform across the table: compute_msl_s_matrix and compute_mixed_s_matrix run preflight automatically, the waveguide and coaxial lanes do not. See each delegate's row in tests/unit/preflight/test_preflight_advisory_emission_contract.py.

See Also

s_matrix_lane : the same choice, without running anything.

def s_matrix_lane(self, *, lane: str | None = None) -> str:

Which S-matrix lane this simulation's registrations select.

Runs no FDTD and touches no grid: it reads the port registries only, so it is cheap to call on a half-built Simulation and it is what the dispatch table's tests assert against.

Parameters

lane : str or None Optional explicit lane name, one of the compute_* names below or "run(compute_s_params=True)". Required on the single-coaxial-port row, where two lanes share one registration footprint (see the module docstring). On every other row it is checked against the lane the registrations determine and raises ValueError if it disagrees -- it can select a lane, never override the physics fence.

Returns

str The delegate's method name, e.g. "compute_waveguide_s_matrix", or the literal "run(compute_s_params=True)" for the lumped/wire family, which has no compute_* method.

Raises

ValueError No ports registered at all (the message lists the add_*_port builders), or a lane= that contradicts the registrations. NotImplementedError A single coaxial port (ambiguous -- pass lane=), or any combination of families no calculator covers. The message names the registered families with their counts.

Examples

>>> sim.add_waveguide_port(...); sim.add_waveguide_port(...)
>>> sim.s_matrix_lane()
'compute_waveguide_s_matrix'
def run( grid: Grid, materials: rfx.core.yee.MaterialArrays, n_steps: int, *, boundary: str = 'pec', cpml_axes: str | None = None, pec_axes: str | None = None, periodic: tuple[bool, bool, bool] | None = None, debye: tuple | None = None, lorentz: tuple | None = None, tfsf: tuple | None = None, _feature_tfsf: bool = False, sources: list[rfx.simulation.SourceSpec] | None = None, probes: list[rfx.simulation.ProbeSpec] | None = None, dft_planes: list | None = None, flux_monitors: list | None = None, waveguide_ports: list | None = None, ntff: object | None = None, current_moments: object | None = None, snapshot: SnapshotSpec | None = None, checkpoint: bool = False, gradient: str = 'autodiff', checkpoint_segments: int | None = None, aniso_eps: tuple | None = None, aniso_inv_eps: tuple | None = None, aniso_inv_eps_smooth: bool = False, pec_mask: object | None = None, pec_sheets: object = (), pec_wires: object = (), pec_edge_masks: object | None = None, pec_occupancy: object | None = None, conformal_weights: tuple | None = None, wire_port_sparams: list | None = None, lumped_port_sparams: list | None = None, wire_refplane_sparams: list | None = None, lumped_rlc: list | None = None, kerr_chi3: jax.jaxlib._jax.Array | None = None, field_dtype=None, return_state: bool = True, mag_sources: list | None = None, stencil_order: int = 2, report_every: int | None = None, report_label: str = '', sheet_impedance: object | None = None, design_box: rfx.simulation.DesignBoxSpec | None = None, design_occupancy: rfx.simulation.DesignOccupancySpec | None = None, stop_fn: Optional[Callable] = None, stop_interval: int = 250, record_dft: bool = False) -> SimResult:

Run a compiled FDTD simulation via jax.lax.scan.

Parameters

grid : Grid materials : MaterialArrays n_steps : int gradient : {"autodiff", "adjoint"} Adjoint requires a uniform real-valued design box. It stores local DFTs instead of checkpoints; checkpoint options are unused. See the module docstring of rfx.adjoint. boundary : "pec", "cpml", or "upml" cpml_axes : str or None: the grid's absorbing axes when omitted; subsets accepted pec_axes : axes string or None Axes on which to enforce PEC after each update. If None, uses all non-periodic axes. periodic : (bool, bool, bool) or None Per-axis periodic boundary flags. If None, defaults to (False, False, False) in 3D and (False, False, True) in 2D modes. debye : (DebyeCoeffs, DebyeState) tuple, or None lorentz : (LorentzCoeffs, LorentzState) tuple, or None tfsf : (TFSFConfig, TFSFState) tuple, or None Optional total-field/scattered-field plane-wave source. When provided, the 1D auxiliary FDTD is interleaved with the 3D Yee updates using the Taflove leapfrog ordering. sources : list of SourceSpec (precomputed waveforms) probes : list of ProbeSpec (point time-series recorders) dft_planes : list of DFTPlaneProbe or None Optional running frequency-domain plane probes. waveguide_ports : list of WaveguidePortConfig or None Optional waveguide-port source/probe configs. ntff : NTFFBox or None If provided, accumulate near-to-far-field DFT on a Huygens box. snapshot : SnapshotSpec or None If provided, record a frame after every snapshot.interval-th step (n_steps // interval frames; see SnapshotSpec), with their positions and times in SimResult.snapshot_axes. Recording does not change any other output: the time series, DFT and port accumulators and the final state are bit-identical to the same run without a snapshot. interval=1 records inside the step body, as it always has; a larger interval runs the steps in blocks of interval (lax.scan over blocks of an inner lax.scan over steps, plus a scan over the remaining n_steps % interval) and records after each block, so the step body is the one a run without a snapshot compiles. The remainder scan is a second compile of the step body; a remainder of ONE step is instead joined with the last step of the last block into a two-step scan (a one-step scan is inlined by XLA and changed the last bit), leaving the rest of that block as a scan of interval - 1 steps. With report_every a chunk that does not start on a multiple of the interval adds a short head scan, and each distinct piece length compiles once (rfx.snapshots.plan_snapshot_pieces lays the pieces out). With checkpoint_segments the interval must divide the segment length n_steps // checkpoint_segments. checkpoint : bool If True, wrap the scan body with jax.checkpoint to trade compute for memory during reverse-mode AD. Reduces backward-pass memory from O(n_steps) to O(1) per step. return_state : bool If False, do not expose the final FDTD state in the result. This shrinks the differentiable output surface for optimisation. report_every : int or None Issue #667. When set, emit one [PROGRESS] ... line to stdout every N steps: steps done / total, wall elapsed, implied rate and ETA. None (default) is OFF and runs the unchanged single-scan code path, so existing callers are byte-identical by construction.

When set, the same compiled scan is driven from the host in
``report_every``-step chunks with ``carry`` threaded through, so the
result is a continuation and not a re-solve; the DFT accumulators
and every port / flux / monitor state live in that carry.
Bit-exactness against ``report_every=None`` is locked by
``tests/unit/runners/test_run_progress_reporting.py``.

Costs, measured rather than hidden: each report inserts one device
synchronisation (without it the host would dispatch every chunk at
once and print a fabricated rate), all full chunks share one XLA
executable while a ragged final chunk compiles once more, and the
per-chunk outputs are joined once at the end with a bounded-arity
grouped concatenate (a flat one takes an argument per chunk, which
cost 232 s at 22,500 chunks against 0.99 s grouped).

Forward-only: it reads the host wall clock, so it raises under
``jax.jit``/``grad``/``vmap`` and is rejected together with
``checkpoint_segments``. The trace check inspects the carry, the
per-step ``xs`` and the material / geometry arrays — the routes
``forward()`` and ``optimize()`` actually trace through — rather
than asking JAX whether a trace is active, which its current API
does not expose. A tracer reaching the scan body by some other
closure alone would print trace-time lines; the computed values
stay byte-identical either way.

report_label : str Short tag prefixed to each progress line, e.g. "MSL drive p1", so the per-drive solves of one compute_*_s_matrix call are distinguishable in a log. Ignored when report_every is None. design_box : DesignBoxSpec or None Issue #1179. One static box whose E update is redone from its own (usually traced) permittivity, leaving materials constant so the reverse-mode tape holds box-shaped arrays instead of grid-shaped ones. None (default) is the unchanged path.

``_resolve_design_box`` rejects, from what this function resolves:
UPML, Debye/Lorentz, anisotropic eps, Kerr, ``stencil_order=4``, the
Bloch path, a box reaching into the CPML absorber, and a box holding
a source, magnetic source, lumped/wire S-param port or RLC cell or a
surface-impedance sheet edge. It does NOT carry the checks that need
the API object: the lane (non-uniform, distributed, ADI), the
collision with ``eps_override`` / ``sigma_override`` /
``mu_r_override``, ``pec_occupancy_override``, a PASSIVE port (it
leaves neither a source nor an accumulator here), and the promotion
of ``materials.eps_r`` to the design dtype. Those are
``Simulation.forward``'s, in ``_resolve_design_box_override``; a
caller reaching this function directly owns them.

design_occupancy : DesignOccupancySpec or None Issue #1183. One static box whose PEC OCCUPANCY is the traced quantity, for the same reason and with the same effect on the tape: the 1 - M scaling is redone on the box's window from the field before the grid-wide apply_pec_occupancy, so what the backward pass keeps per step is window-shaped. None (default) is the unchanged path. The box's occupancy REPLACES pec_occupancy at the box cells; outside it, pec_occupancy is carried.

``_resolve_design_occupancy`` rejects a periodic axis, an empty box
and a box off the grid. ``Simulation.forward`` owns the lane and the
collision with ``design_eps_override``.

stop_fn : callable or None Issue #1254 (Simulation.run(..., until_identified=True)). When given, the scan runs in chunks of stop_interval steps through rfx.progress.scan_with_progress(), the carry threaded through, and stop_fn(steps_done, peek) is called after each chunk; peek() returns the record so far as a namespace with time_series ((steps_done, n_probes)), wire_port_sparams (the (spec, accumulators) pairs at that step, as this function returns them), grid and dt. A true return ends the run there, and every output is the one a run of steps_done steps returns (the chunked scan is a continuation of the single one). Refused with a snapshot or checkpoint_segments. None (default) is the unchanged path. stop_interval : int Chunk length of the stop_fn loop (default 250). Ignored when stop_fn is None.

Returns

SimResult with final state, time series, and optional NTFF data.

def run_until_decay( grid: Grid, materials: rfx.core.yee.MaterialArrays, *, decay_by: float = 0.001, check_interval: int = 50, min_steps: int = 100, max_steps: int = 50000, decay_energy_consecutive: int = 2, radiated_flux_box: tuple | None = None, flux_env_checks: int = 4, monitor_component: str = 'ez', monitor_position: tuple[float, float, float] | None = None, boundary: str = 'pec', cpml_axes: str | None = None, pec_axes: str | None = None, periodic: tuple[bool, bool, bool] | None = None, debye: tuple | None = None, lorentz: tuple | None = None, tfsf: tuple | None = None, _feature_tfsf: bool = False, sources: list[rfx.simulation.SourceSpec] | None = None, probes: list[rfx.simulation.ProbeSpec] | None = None, dft_planes: list | None = None, flux_monitors: list | None = None, waveguide_ports: list | None = None, ntff: object | None = None, current_moments: object | None = None, snapshot: SnapshotSpec | None = None, checkpoint: bool = False, aniso_eps: tuple | None = None, aniso_inv_eps: tuple | None = None, aniso_inv_eps_smooth: bool = False, pec_mask: object | None = None, pec_sheets: object = (), pec_wires: object = (), pec_edge_masks: object | None = None, pec_occupancy: object | None = None, conformal_weights: tuple | None = None, wire_port_sparams: list | None = None, lumped_port_sparams: list | None = None, lumped_rlc: list | None = None, kerr_chi3: jax.jaxlib._jax.Array | None = None, field_dtype=None, return_state: bool = True, mag_sources: list | None = None, checkpoint_segments: int | None = None, stencil_order: int = 2, report_every: int | None = None, report_label: str = '', sheet_impedance: object | None = None, record_dft: bool = False) -> SimResult:

Run simulation until field energy decays to decay_by of peak.

Runs compiled scan chunks with host-side decay checks at the historical step indices (0, check_interval, ...).

Parameters

decay_by : float Stop when |field|^2 < decay_by * peak|field|^2. check_interval : int Check decay every N steps. min_steps : int Always run at least this many steps. max_steps : int Hard upper limit on steps. decay_energy_consecutive : int On absorbing boundaries (cpml / upml) the stop uses the total interior-domain energy criterion (issue #169). It fires only after the interior energy U has stayed below decay_by * peak_U on this many consecutive checks. Default 2 (MANDATORY minimum): the interior energy of a guided geometry is not null-free, dipping through transient inter-packet minima that recover; a single below- threshold check can false-fire on such a dip, so >= 2 is required to absorb the chatter. Has no effect on closed/PEC boundaries (which use the instantaneous point-field fallback). radiated_flux_box : tuple or None Opt-in (#388) RADIATED-FLUX stop for absorbing boundaries: a Huygens box ((x_lo, y_lo, z_lo), (x_hi, y_hi, z_hi)) in physical coordinates enclosing the structure (clear of the CPML). When set, the stop uses the outgoing Poynting flux through the box instead of the interior energy — a radiation-settling criterion that ignores non-radiating trapped energy (the static soft-source charge that FLOORS the energy criterion, and near-Nyquist grid buzz), because neither carries net radiated power. Appropriate for radiation / S-parameter measurements; the flux peak is tracked from the first check (the radiation peaks during the source drive). Default None keeps the interior-energy criterion, unchanged. flux_env_checks : int Number of recent checks whose |flux| max forms the envelope used by the radiated-flux stop, smoothing the 2f0 Poynting oscillation. Default 4. monitor_component : str Field component to monitor ("ez", "hy", etc.). Used only by the closed/PEC point-field fallback stop. monitor_position : tuple or None Physical position (x, y, z) to monitor. If None, use center of the domain. checkpoint_segments : int or None Not supported on the decay path. run_until_decay uses a host-driven early stop, so scan-level gradient checkpointing does not apply. Passing a non-None value raises NotImplementedError.

Notes

Differences from run():

  • checkpoint_segments is not supported (raises NotImplementedError when not None).
  • snapshot records a frame after every snapshot.interval-th step of the loop, as run() does (#1258): actual_steps // interval frames, positions and times in SimResult.snapshot_axes. Reading the frame does not touch the stepped state.
  • checkpoint (jax.checkpoint gradient tape) is accepted but silently ignored — gradient checkpointing has no effect on the host-driven decay path.
  • Flux-monitor DFT accumulation uses a rectangular (no-window) weight instead of the streaming Hann window used by run(). Flux values from the two paths are therefore not numerically identical even for the same step count.

The stop criterion depends on the boundary (issue #169, RESOLVED for absorbing boundaries).

  • Absorbing boundaries (cpml / upml) — the stop is gated on the total interior-domain energy U = sum(E^2 + H^2) over the non-CPML interior slice, declared decayed once U < decay_by * peak_U on decay_energy_consecutive consecutive checks. Because it is a whole-domain energy, it does not pass through the per-cell interference nulls that the old single-cell point-field stopper hit between slow-tail wave packets, so it is suitable for flux / S-parameter / transmission measurements on guided / low-loss geometries. (On the cv03-class eps=12 guide it now stops at the flux-converged transmission within ~0.13%, vs the prior ~7% under-run of the point-field stop.) decay_energy_consecutive >= 2 is mandatory because the interior energy is not null-free — it dips through transient inter-packet minima that recover, so a single below-threshold check can false-fire.

  • Closed / PEC boundaries — domain energy does not decay in a lossless closed cavity, so the stop falls back to the historical instantaneous squared field at the single monitor_component cell (val_sq < decay_by * peak_sq). This fallback retains the original limitation: it is a valid decay witness only for lossy / radiating structures with a clean ring-down envelope, and is not suitable for flux / S-parameter / transmission gating on guided / low-loss closed geometries — for those use a fixed n_steps via run().

report_every : int or None Issue #667. When set, emit one [PROGRESS] ... line every N steps, at the first decay-chunk boundary at or past the interval, plus a final line at the actual stop step. None (default) is OFF. Reporting never changes the chunks or the decay-check cadence. The denominator is max_steps, i.e. a CAP: the line marks it (cap) and the ETA is an upper bound, because a decay stop can fire at any check. report_label : str Short tag prefixed to each progress line. Ignored when report_every is None.

Returns

SimResult

def run_nonuniform( grid: NonUniformGrid, materials: rfx.core.yee.MaterialArrays, n_steps: int, *, cpml_axes: str | None = None, pec_mask=None, pec_sheets=(), pec_wires=(), pec_edge_masks=None, pec_occupancy=None, sources: list = None, probes: list = None, wire_ports: list = None, s_param_freqs=None, debye: tuple | None = None, lorentz: tuple | None = None, pec_faces: set[str] | None = None, pmc_faces: set[str] | None = None, dft_planes: list | None = None, rlc_metas: tuple = (), rlc_states: tuple = (), ntff_box=None, ntff_data=None, current_moments=None, waveguide_ports: list | None = None, tfsf: tuple | None = None, _feature_tfsf: bool = False, flux_monitors: list | None = None, checkpoint: bool = False, emit_time_series: bool = True, checkpoint_every: int | None = None, n_warmup: int = 0, aniso_eps: tuple | None = None, sheet_impedance=None, design_box=None, record_dft: bool = False) -> dict:

Run non-uniform FDTD via jax.lax.scan.

Parameters

cpml_axes : str or None: axes with absorber pads when omitted; subsets accepted sources : list of (i, j, k, component, waveform_array) probes : list of (i, j, k, component) wire_ports : list of dict with keys: mid_i, mid_j, mid_k, component, impedance, waveform_array s_param_freqs : (n_freqs,) array for S-param DFT debye : (DebyeCoeffs, DebyeState) or None lorentz : (LorentzCoeffs, LorentzState) or None dft_planes : list of DFTPlaneProbe or None Frequency-domain plane accumulators. The accumulation is identical to the uniform path (acc += field * exp(-j2pi f t) * dt); dt is scalar on both paths so no per-axis weighting is required.

def make_source( grid: Grid, position, component, waveform_fn, n_steps, materials=None, amplitude_kind=None):

Create a SourceSpec by precomputing a waveform function (raw E-field add).

WARNING: raw field source causes DC accumulation on PEC surfaces. Prefer make_j_source for resonance detection.

Native amplitude convention (issue #571): 'field' — E += w. amplitude_kind='current' uses the shared source-table builder's Cb/dV coefficient and REQUIRES materials (for Cb at the source cell). amplitude_kind of None or 'field' is bit-identical to the historical output (no multiply is applied at all). materials is accepted and unused in that legacy/native case.

def make_probe(grid: Grid, position, component):

Create a ProbeSpec from a physical position.

def make_port_source( grid: Grid, port, materials: rfx.core.yee.MaterialArrays, n_steps):

Create a SourceSpec for a lumped port (Cb-corrected waveform).

The port impedance must already be folded into materials via setup_lumped_port().

def make_current_source( grid: NonUniformGrid, position_ijk, component, waveform_fn, n_steps, materials, amplitude_kind=None):

Create a properly normalized current source for non-uniform grid.

The waveform specifies CURRENT (Amperes). The E-field addition is: E += (dt/ε) × I_source / dV where dV is the E node's control volume: the PRIMAL per-cell width on the component's own axis and the DUAL spacing (d[k-1]+d[k])/2 on the two transverse axes (issue #672). On a uniform mesh the two coincide and this is the familiar dx × dy × dz.

This gives resolution-independent injected POWER regardless of cell size. Same approach as Meep's internal source normalization.

Native amplitude convention (issue #571): 'current' — amplitude_kind of None or 'current' is bit-identical to the historical output (no extra multiply). amplitude_kind='field' returns the waveform directly, without reading a material coefficient. The shared builder preserves the current drive's differentiable Cb/dV.

class SimResult(typing.NamedTuple):

Compiled simulation output.

time_series : (n_steps, n_probes) float array, or (n_steps, 0) if no probes were specified. ntff_data : NTFFData or None Accumulated near-to-far-field DFT data (if NTFF box was used). dft_planes : tuple[DFTPlaneProbe, ...] or None Final accumulated DFT plane probes. waveguide_ports : tuple[WaveguidePortConfig, ...] or None Final accumulated waveguide-port configs. wire_port_sparams : tuple | None Final V/I/V_inc DFT accumulators for wire port S-params. lumped_port_sparams : tuple | None Final V/I DFT accumulators for lumped port S-params (issue #72). Each entry is (LumpedPortSParamSpec, (v_dft, i_dft, v_ref_dft)); v/i are POST-injection (i with the Yee half-step phase) and v_ref is the PRE-injection drive sample. wire_refplane_sparams : tuple | None Final reference-plane V/I DFT accumulators for the opt-in wire S-matrix plane path (issue #313). Each entry is (WireRefPlaneSpec, (v_dft, i_minus_dft, i_plus_dft)); two entries per opted port (plane slots 0 and 1). snapshots : dict[str, ndarray] or None Field snapshots keyed by component name, each (n_steps // interval, ...); see SnapshotSpec. ntff_box : NTFFBox or None NTFF box specification used for accumulation. grid : Grid or None Grid metadata needed by post-processing / objective helpers. snapshot_axes : dict[str, rfx.snapshots.SnapshotAxes] or None Per snapshot component: sample coordinates in metres, the slice plane, and the step count and time of each frame (#1259). None when no snapshot was recorded. dt : float or None The time step the scan actually advanced by, in seconds. It is grid.dt except under stencil_order=4, whose step is derated by _ORDER4_CFL_FACTOR; the time series and every frame are sampled at this step, so time and frequency must be read with it, not with grid.dt. current_moment_data : (accumulator, kahan) or None Block current moments accumulated inside the time loop (rfx.current_moments). The accumulator is (n_freqs, n_blocks, 3, n_weights). current_moment_monitor : CurrentMomentMonitor or None The slab, block map and centres those moments belong to.

SimResult( state: ForwardRef('FDTDState | None'), time_series: ForwardRef('jnp.ndarray'), ntff_data: object = None, dft_planes: tuple | None = None, flux_monitors: tuple | None = None, waveguide_ports: tuple | None = None, wire_port_sparams: tuple | None = None, lumped_port_sparams: tuple | None = None, snapshots: dict | None = None, ntff_box: object = None, grid: ForwardRef('Grid | None') = None, wire_refplane_sparams: tuple | None = None, snapshot_axes: dict | None = None, dt: float | None = None, current_moment_data: object = None, current_moment_monitor: object = None, adjoint_settling: object = None, sparam_time_records: tuple | None = None, dft_time_records: tuple | None = None)

Create new instance of SimResult(state, time_series, ntff_data, dft_planes, flux_monitors, waveguide_ports, wire_port_sparams, lumped_port_sparams, snapshots, ntff_box, grid, wire_refplane_sparams, snapshot_axes, dt, current_moment_data, current_moment_monitor, adjoint_settling, sparam_time_records, dft_time_records)

state: rfx.core.yee.FDTDState | None

Alias for field number 0

time_series: jax.jaxlib._jax.Array

Alias for field number 1

ntff_data: object

Alias for field number 2

dft_planes: tuple | None

Alias for field number 3

flux_monitors: tuple | None

Alias for field number 4

waveguide_ports: tuple | None

Alias for field number 5

wire_port_sparams: tuple | None

Alias for field number 6

lumped_port_sparams: tuple | None

Alias for field number 7

snapshots: dict | None

Alias for field number 8

ntff_box: object

Alias for field number 9

grid: Grid | None

Alias for field number 10

wire_refplane_sparams: tuple | None

Alias for field number 11

snapshot_axes: dict | None

Alias for field number 12

dt: float | None

Alias for field number 13

current_moment_data: object

Alias for field number 14

current_moment_monitor: object

Alias for field number 15

adjoint_settling: object

Alias for field number 16

sparam_time_records: tuple | None

Alias for field number 17

dft_time_records: tuple | None

Alias for field number 18

class SnapshotSpec(typing.NamedTuple):

Mid-simulation field snapshot specification.

interval : int Record a frame every interval time steps; an integer >= 1, anything else is refused. Counting is 1-based over completed steps: a run of n_steps records n_steps // interval frames, frame k (0-based) holding the fields after step (k + 1) * interval, i.e. after steps interval, 2*interval, .... The last n_steps % interval steps record no frame. interval=1 records every step. Memory scales with the frame count. components : tuple of str Field components to capture, e.g. ("ez",) or ("ex", "hy"). slice_axis : int or None 0, 1 or 2. If not None and slice_index is not None, capture a 2-D slice at slice_index along this axis instead of the full 3-D field (saves memory). With slice_index None the full field is recorded. slice_index : int or None PADDED lattice index along slice_axis: the same index space as grid.shape and grid.position_to_index, CPML cells counted (grid.position_to_index(pos)[axis] gives the index of a physical position).

Where each recorded sample sits (metres) and when each frame was taken (seconds): Result.snapshot_axes after a run, or rfx.snapshot_axes(grid, spec, n_steps) before one (rfx/snapshots.py states the lattice layout and the E/H time convention).

SnapshotSpec( interval: int = 10, components: tuple = ('ez',), slice_axis: int | None = None, slice_index: int | None = None)

Create new instance of SnapshotSpec(interval, components, slice_axis, slice_index)

interval: int

Alias for field number 0

components: tuple

Alias for field number 1

slice_axis: int | None

Alias for field number 2

slice_index: int | None

Alias for field number 3

class Result(typing.NamedTuple):

Structured simulation result.

Attributes

state : FDTDState Final field state (useful for visualization). time_series : (n_steps, n_probes) float array Probe recordings over time. s_params : (n_ports, n_ports, n_freqs) complex or None S-parameter matrix (computed only when ports are present and compute_s_params=True). With no driven port, passive-port ratios from the main record are termination diagnostics, not an S-matrix of the structure under independent port drives. freqs : (n_freqs,) float or None Frequency array for S-parameters. ntff_data : NTFFData or None Raw NTFF DFT data (use compute_far_field for radiation pattern). ntff_box : NTFFBox or None NTFF box specification (needed for compute_far_field). dft_planes : dict[str, DFTPlaneProbe] or None Frequency-domain plane probes keyed by name. waveguide_ports : dict[str, WaveguidePortConfig] or None Final accumulated waveguide-port configs keyed by name. waveguide_sparams : dict[str, WaveguideSParamResult] or None High-level calibrated waveguide S-parameters keyed by port name. snapshots : dict[str, ndarray] or None Field snapshots keyed by component name, each (n_steps // interval, ...): frame k holds the fields after step (k + 1) * interval (see SnapshotSpec). Indexed on the padded lattice, like state. grid : Grid or None Grid metadata for post-processing helpers and advanced objectives. settling_db : float or None Worst identified pole-tail share over recorded channels and read bins, in amplitude dB. Missing identification yields a non-pass witness. settling_witness : dict or None Status, reason, per-bin tail shares and identification errors, and selected-record provenance for the value witness.

Result( state: object, time_series: ForwardRef('jnp.ndarray'), s_params: ForwardRef('np.ndarray | None'), freqs: ForwardRef('np.ndarray | None'), ntff_data: object = None, ntff_box: object = None, dft_planes: dict | None = None, flux_monitors: dict | None = None, waveguide_ports: dict | None = None, waveguide_sparams: dict | None = None, waveguide_port_flux: tuple | None = None, snapshots: dict | None = None, grid: object = None, dt: float | None = None, freq_range: tuple | None = None, settling_db: float | None = None, settling_witness: dict | None = None, wire_port_sparams: tuple | None = None, ringdown: object = None, snapshot_axes: dict | None = None, current_moment_data: object = None, current_moment_monitor: object = None, realized_geometry: object = None, sparam_time_records: tuple | None = None, dft_time_records: object = None, diagnostics: ForwardRef('tuple[Diagnostic, ...]') = ())

Create new instance of Result(state, time_series, s_params, freqs, ntff_data, ntff_box, dft_planes, flux_monitors, waveguide_ports, waveguide_sparams, waveguide_port_flux, snapshots, grid, dt, freq_range, settling_db, settling_witness, wire_port_sparams, ringdown, snapshot_axes, current_moment_data, current_moment_monitor, realized_geometry, sparam_time_records, dft_time_records, diagnostics)

state: object

Alias for field number 0

time_series: jax.jaxlib._jax.Array

Alias for field number 1

s_params: numpy.ndarray | None

Alias for field number 2

freqs: numpy.ndarray | None

Alias for field number 3

ntff_data: object

Alias for field number 4

ntff_box: object

Alias for field number 5

dft_planes: dict | None

Alias for field number 6

flux_monitors: dict | None

Alias for field number 7

waveguide_ports: dict | None

Alias for field number 8

waveguide_sparams: dict | None

Alias for field number 9

waveguide_port_flux: tuple | None

Alias for field number 10

snapshots: dict | None

Alias for field number 11

grid: object

Alias for field number 12

dt: float | None

Alias for field number 13

freq_range: tuple | None

Alias for field number 14

settling_db: float | None

Alias for field number 15

settling_witness: dict | None

Alias for field number 16

wire_port_sparams: tuple | None

Alias for field number 17

ringdown: object

Alias for field number 18

snapshot_axes: dict | None

Alias for field number 19

current_moment_data: object

Alias for field number 20

current_moment_monitor: object

Alias for field number 21

realized_geometry: object

Alias for field number 22

sparam_time_records: tuple | None

Alias for field number 23

dft_time_records: object

Alias for field number 24

diagnostics: tuple[Diagnostic, ...]

Alias for field number 25

def find_resonances( self, freq_range=None, probe_idx=0, source_decay_time=None, bandpass=None, waveform=None):

Extract resonant modes from probe time series via Harminv.

Parameters

freq_range : (f_min, f_max) in Hz, or None to use stored range probe_idx : which probe to analyze source_decay_time : float or None Time (s) after which source has decayed. If None, auto- computed as 2×(3/π/f_center/bandwidth) — skips the Gaussian excitation region for clean ring-down analysis. bandpass : bool or None Apply FFT bandpass before Harminv. Default: auto (True for CPML results where DC/surface-wave artifacts exist, False for PEC cavities where signal is clean). waveform : source waveform or None Used only when source_decay_time is None: when the waveform exposes t0 (GaussianPulse / ModulatedGaussian: t0 = cutoff*tau), the auto window starts at 2*t0 so a longer-onset pulse (e.g. cutoff=4.5 → 9*tau) is fully skipped. Without a waveform the historical cutoff=3 envelope (2*(3*tau)) is used, bitwise-identical to the previous hardcode.

Returns

list of HarminvMode

def assert_finite(self, *, raise_on_nonfinite: bool = False) -> bool:

Check that the result's observables contain no NaN/Inf.

A non-finite time_series or s_params almost always means the FDTD diverged rather than that the device is exotic. Returns True when finite. With raise_on_nonfinite=True raises ValueError instead of warning, so an automation loop can fail fast with a cause hint right after run() instead of propagating silent garbage into a downstream metric. Tracer-safe — a no-op under jax.grad/jit.

Returns

bool True if all inspected observables are finite (or unavailable for inspection, e.g. under tracing), False otherwise.

freqs_hz: numpy.ndarray

Frequency vector in Hz (freqs is already stored in Hz).

Returns

(n_freqs,) float array

Raises

ValueError If this Result carries no frequency vector.

def s(self, m: int, n: int) -> numpy.ndarray:

Complex S-parameter vector S_mn vs frequency (1-indexed ports).

Parameters

m, n : int 1-indexed port numbers. s(1, 1) is S11.

Returns

(n_freqs,) complex array

Raises

ValueError If this Result has no S-parameters, or if m/n are out of range (the available port count is named in the message).

def s11(self) -> numpy.ndarray:

Complex S11 vector (port1->port1). Valid for >=1 port.

def s21(self) -> numpy.ndarray:

Complex S21 vector (port1->port2). Valid for >=2 ports.

def s12(self) -> numpy.ndarray:

Complex S12 vector (port2->port1). Valid for >=2 ports.

def s22(self) -> numpy.ndarray:

Complex S22 vector (port2->port2). Valid for >=2 ports.

def s_db(self, m: int, n: int) -> numpy.ndarray:

Magnitude of S_mn in dB: 20*log10(|S_mn|) (1-indexed ports).

The magnitude is floored at 1e-10 before the log (matching the floor used by rfx.visualize.plot_s_params()) so an exact zero yields a large negative dB value instead of -inf and the numeric accessor agrees with the plotted curve at deep nulls.

def plot_s_params(self, *, db: bool = True, title: str = 'S-Parameters'):

Plot all S-parameter magnitudes vs frequency.

Thin wrapper over rfx.visualize.plot_s_params(), which builds and returns its own matplotlib Figure (it does not accept an externally supplied Axes).

Parameters

db : bool Plot magnitudes in dB (default) or linear. title : str Plot title.

Returns

matplotlib Figure

Raises

ValueError If this Result has no S-parameters.

def plot_smith(self, *, ports: tuple[int, int] | None = None, **kw):

Plot an S-parameter trajectory on a Smith chart.

Thin wrapper over rfx.smith.plot_smith().

Parameters

ports : (m, n) tuple of 1-indexed ports, optional Which S-parameter to plot. Defaults to (1, 1) (S11). **kw Forwarded to rfx.smith.plot_smith() (e.g. z0, ax, show_vswr, markers, title).

Returns

matplotlib Axes

Raises

ValueError If this Result has no S-parameters, or ports are out of range.

def plot_time_series(self, *, labels=None, title: str = 'Probe Time Series'):

Plot the probe time series.

Thin wrapper over rfx.visualize.plot_time_series(). Requires dt to be present. Simulation.run() populates it automatically; supply dt when constructing a Result manually.

Parameters

labels : list of str, optional Per-probe labels. title : str Plot title.

Returns

matplotlib Figure

Raises

ValueError If dt is not available in this Result.

class ForwardResult(typing.NamedTuple):

Minimal differentiable simulation result.

Carries only the observables needed by gradient-based objectives, avoiding the broader stateful surface of Result. The settling_db and settling_witness properties score the concrete user-probe record using the same host diagnostic as run(). They report absence while records are traced; read them on the returned concrete result, not as gradient objectives. Numeric settling_probe_info preserves the selected columns, component labels and the #1090 source-dominated flag as integers, without inserting strings into a JAX result tree. settling_source_end_index carries the first post-source sample, including free-space transit; missing timing or insufficient post-source samples yields no witness. These lazy properties are not stored fields in _asdict(). wire_port_sparams is a tuple of (meta, accs) pairs on both lanes; the lane-specific type of meta and the accumulator channels are documented on Result.

lumped_port_sparams exposes the raw per-port (V_dft, I_dft, V_ref_dft) tuples accumulated inside the JIT scan body when forward(port_s11_freqs=...) is used. V and I are the POST-injection physical channels (I carrying the Yee half-step phase); V_ref is the PRE-injection drive sample, which only the N-port off-diagonal incident wave consumes. Single-port objectives can keep using s_params (per-port S11: the driven terminal reflection rfx.probes.probes.driven_port_reflection() at an excited port, the port-branch extract_lumped_s11() at a passive one). Multi-port AD objectives (e.g. 2-port |S21| topology optimisation) read the raw channels from this field and compose their own wave decomposition, since a diagonal collapses each port to its self-reflection only.

dft_planes exposes the JIT-scan-accumulated complex DFT plane probes registered via Simulation.add_dft_plane_probe(). Each entry is a DFTPlaneProbe whose accumulator field is a JAX-traceable complex array shaped (n_freqs, *plane_shape), with plane-resolved field values usable by gradient-based objectives that need plane-integrated V/I (e.g. waveguide-port or microstrip-port line-integrated voltage / closed-loop current). None when no plane probes were registered.

dt is the time step the solver advanced by, the spacing of time_series. Read time and frequency with it, not with grid.dt: stencil_order=4 steps at 0.857 * grid.dt.

ringdown is a rfx.ringdown.RingdownForwardResult when forward(ringdown=RingdownSpec()) was called (issue #1254): the completed wire-port S-parameters, traced and differentiable, and a lazy host report on the concrete result. None otherwise.

design_box_held_edges is the tuple of (axis, i, j, k) E edges a permittivity design box held on the drawn materials' coefficients (forward(design_box_holds_ports=True); axis 0, 1, 2 for Ex, Ey, Ez): () for a box that held none, None when no such box was given.

adjoint_settling is the primal design-edge max|E| over the last period of the lowest monitor bin (capped at the record length), divided by the record maximum. Above about 1e-2 means unsettled; small proves nothing: it follows the strongest field and says nothing about the adjoint record. None for autodiff, NaN for zero signal. Use rfx.gradient_record_length_witness() to witness an adjoint gradient.

ForwardResult( time_series: ForwardRef('jnp.ndarray'), ntff_data: object = None, ntff_box: object = None, grid: object = None, s_params: object = None, freqs: object = None, lumped_port_sparams: object = None, wire_port_sparams: object = None, dft_planes: object = None, settling_probe_info: object = None, dt: object = None, ringdown: object = None, design_box_held_edges: object = None, current_moment_data: object = None, current_moment_monitor: object = None, adjoint_settling: object = None, settling_source_end_index: object = None, settling_freq_max: object = None, sparam_time_records: tuple | None = None, dft_time_records: object = None, diagnostics: ForwardRef('tuple[Diagnostic, ...]') = ())

Create new instance of ForwardResult(time_series, ntff_data, ntff_box, grid, s_params, freqs, lumped_port_sparams, wire_port_sparams, dft_planes, settling_probe_info, dt, ringdown, design_box_held_edges, current_moment_data, current_moment_monitor, adjoint_settling, settling_source_end_index, settling_freq_max, sparam_time_records, dft_time_records, diagnostics)

time_series: jax.jaxlib._jax.Array

Alias for field number 0

ntff_data: object

Alias for field number 1

ntff_box: object

Alias for field number 2

grid: object

Alias for field number 3

s_params: object

Alias for field number 4

freqs: object

Alias for field number 5

lumped_port_sparams: object

Alias for field number 6

wire_port_sparams: object

Alias for field number 7

dft_planes: object

Alias for field number 8

settling_probe_info: object

Alias for field number 9

dt: object

Alias for field number 10

ringdown: object

Alias for field number 11

design_box_held_edges: object

Alias for field number 12

current_moment_data: object

Alias for field number 13

current_moment_monitor: object

Alias for field number 14

adjoint_settling: object

Alias for field number 15

settling_source_end_index: object

Alias for field number 16

settling_freq_max: object

Alias for field number 17

sparam_time_records: tuple | None

Alias for field number 18

dft_time_records: object

Alias for field number 19

diagnostics: tuple[Diagnostic, ...]

Alias for field number 20

settling_db: float | None

Host ring-down diagnostic from the concrete recorded user probes.

None means absent, including during tracing. This is a diagnostic, not a differentiable objective. A returned JIT result can be scored once its time series is concrete. Other result fields retain their existing JIT restrictions (for example the host Grid object). Use settling_verdict to judge the diagnostic.

settling_witness: dict

Provenance for settling_db, without string leaves in JAX trees.

class WaveguideSParamResult(typing.NamedTuple):

High-level calibrated waveguide S-parameter data.

WaveguideSParamResult( freqs: ForwardRef('np.ndarray'), s11: ForwardRef('np.ndarray'), s21: ForwardRef('np.ndarray'), calibration_preset: str, source_plane: float, measured_reference_plane: float, measured_probe_plane: float, reference_plane: float, probe_plane: float)

Create new instance of WaveguideSParamResult(freqs, s11, s21, calibration_preset, source_plane, measured_reference_plane, measured_probe_plane, reference_plane, probe_plane)

freqs: numpy.ndarray

Alias for field number 0

s11: numpy.ndarray

Alias for field number 1

s21: numpy.ndarray

Alias for field number 2

calibration_preset: str

Alias for field number 3

source_plane: float

Alias for field number 4

measured_reference_plane: float

Alias for field number 5

measured_probe_plane: float

Alias for field number 6

reference_plane: float

Alias for field number 7

probe_plane: float

Alias for field number 8

class WaveguideSMatrixResult(typing.NamedTuple):

Waveguide scattering result assembled one driven port at a time.

settling_db records the worst per-bin pole-tail amplitude share across modal V/I records for each drive. Paired extractions include device and reference runs. settling_witness carries identification status, reasons, and per-bin shares and errors.

s21_phase_residual_deg_rms is the post-solve discretization witness (#894, post-v1.8 plan item 5): the RMS over the measured bins of wrap(angle(S21) + beta(f)*L), in degrees, with beta the EXTRACTOR'S OWN (_compute_beta at the port config's discrete f_cutoff, dt and dx) and L the separation of the two reference planes reported in reference_planes. Unlike a near-cutoff |S11| headline it does not pass through the absorber: measured invariant to absorber thickness (3x), record length (2.5x) and precision, and second order in dx at every band down to f/f_c = 1.010 (tests/fixtures/waveguide_vi_envelope/s21_phase_residual_witness.json). So it answers "is the port the problem?" when a reflection number looks wrong -- for an EMPTY or matched guide between the two reference planes. A device between them adds its own transmission phase to angle(S21), and the number is then the device's phase against -beta*L, not a port witness. It is REPORTED, never gated — nothing in the library compares it with a threshold. None when the residual is not defined: a result that is not a single-mode two-port, a traced AD run, two ports with different discrete cutoffs or on different axes, a band whose every bin fell below the weak-signal phase mask (rfx.api._sparams.WAVEGUIDE_PHASE_MAG_FLOOR), or a normalize=True lane, whose S21 has the empty-guide reference's propagation phase divided out so the residual is not defined there (normalize="flux" and normalize=False keep it). s21_phase_residual_meta always carries beta_convention, f_cutoff_hz, L_m, n_bins, masked_bins and normalize (the lane the number was read under), plus a reason when the value is None.

WaveguideSMatrixResult( s_params: ForwardRef('np.ndarray'), freqs: ForwardRef('np.ndarray'), port_names: tuple[str, ...], port_directions: tuple[str, ...], reference_planes: ForwardRef('np.ndarray'), settling_db: ForwardRef('np.ndarray | None') = None, s21_phase_residual_deg_rms: float | None = None, s21_phase_residual_meta: dict | None = None, settling_witness: tuple | None = None)

Create new instance of WaveguideSMatrixResult(s_params, freqs, port_names, port_directions, reference_planes, settling_db, s21_phase_residual_deg_rms, s21_phase_residual_meta, settling_witness)

s_params: numpy.ndarray

Alias for field number 0

freqs: numpy.ndarray

Alias for field number 1

port_names: tuple[str, ...]

Alias for field number 2

port_directions: tuple[str, ...]

Alias for field number 3

reference_planes: numpy.ndarray

Alias for field number 4

settling_db: numpy.ndarray | None

Alias for field number 5

s21_phase_residual_deg_rms: float | None

Alias for field number 6

s21_phase_residual_meta: dict | None

Alias for field number 7

settling_witness: tuple | None

Alias for field number 8

@dataclass(frozen=True)
class MSLProbeClearance:

Per-MSL-port downstream-reflector layout diagnosis, in metres.

satisfied means no considered conductor candidate violates the existing recommendation. insufficient records a known violation; unavailable means missing coordinates or unevaluated conductors prevent that conclusion. A known violation remains insufficient even when other conductors could not be evaluated.

This uses registered conductor bounds and the existing candidate scan, not a modal-purity or S-accuracy test. It does not replace source/absorber, settling or low-signal checks. Negative gaps mean the probe has passed the candidate's first boundary along the port direction. None gaps with satisfied status mean the scan found no candidate. Probe coordinates are scalar positions on axis. The threshold is evaluated at the simulation's design frequency, not independently at every result bin.

MSLProbeClearance( port_name: str, axis: str, status: Literal['satisfied', 'insufficient', 'unavailable'], rule_frequency_hz: float, recommended_gap_m: float | None, first_probe_m: float | None = None, deepest_probe_m: float | None = None, first_gap_m: float | None = None, deepest_gap_m: float | None = None, reflector: str | None = None, unevaluated_conductors: tuple[str, ...] = (), note: str | None = None)
port_name: str
axis: str
status: Literal['satisfied', 'insufficient', 'unavailable']
rule_frequency_hz: float
recommended_gap_m: float | None
first_probe_m: float | None = None
deepest_probe_m: float | None = None
first_gap_m: float | None = None
deepest_gap_m: float | None = None
reflector: str | None = None
unevaluated_conductors: tuple[str, ...] = ()
note: str | None = None
@dataclass
class MSLSMatrixResult:

MSL S-matrix result.

Attributes

S : (n_ports, n_ports, n_freqs) complex Power-wave S-matrix at each port's FIRST probe plane, formed from a=(V+RI)/(2sqrt(R)), b=(V-RI)/(2sqrt(R)) with the positive real analytic Hammerstad-Jensen reference R of each port. There is no translation back to the physical feed planes. Bracketing H samples are interpolated to this E-node plane using physical coordinates, with the temporal leapfrog offset corrected separately. The fitted Z0 below is a diagnostic, not the reference used to form S. reference_impedances : (n_ports,) float, optional Actual positive real R values used to define S, in ohms and in port_names order. These are distinct from fitted Z0 and the source/load resistances. Present on generated results; None is retained for manually constructed legacy result objects. freqs : (n_freqs,) float Frequency grid in Hz. Z0 : (n_ports, n_freqs) complex Characteristic impedance extracted via the N-probe SVD least-squares wave-decomposition fit (issue #80 Fix C), per driven-port run (Z0[i, :] is from run with port i driven). beta : (n_freqs,) complex Propagation constant β from the N-probe least-squares fit (issue #80 Fix C) at the first port's run. reliable : (n_ports, n_freqs) bool, optional Relative low-signal mask. reliable[p, k] is False when both |V| and |I| at port p fall below 10% of that record's own band medians in at least one driven run. All drive/port records used by the solve are covered; S is retained unchanged.

A False entry does not by itself prove matrix corruption: a true
transmission zero can have zero passive-port phasors with a
well-conditioned drive matrix. Inspect absolute signal uncertainty,
settling and drive conditioning before interpreting those bins.
A True entry is not an accuracy certificate either. The numerical
threshold and the policies used by callers are unchanged.

probe_clearance : tuple[MSLProbeClearance, ...], optional Separate downstream-reflector layout diagnosis in port order, using the resolved probe ladder. Status is satisfied, insufficient or unavailable; physical probe coordinates and signed gap estimates accompany it. This is independent of reliable and does not certify mode purity or S accuracy.

**The two fields answer different questions** (issue #726).
``reliable`` is a per-bin FIT-QUALITY mask; ``probe_clearance`` is
the GEOMETRIC condition, and neither is the other's proxy — on the
board runs that opened #726 ``reliable`` was True on 100 % of in-band
bins while the fitted Z0 was 2.4x the analytic value. What the
geometric condition costs was measured (VESSL 369367260508, cv06b
fixed-source, same source/load/DUT with only the p1 observation
offset varied, both arms settled below −118 dB): the FITTED Z0/beta
are what corrupt — the near-reflector arm's β scan railed on 51/51
bins over 3–5 GHz against 0/51 for the compliant arm — while raw S11
at the 3.77125 GHz notch bin read +0.026895 dB on the near-reflector
arm and +0.018065 dB on the compliant one, against the analytic 0 dB
that quarter-wave open-stub notch has. Both sit ABOVE unity on a
passive structure, by 0.31 % and 0.21 %, which is never reported here
as physics: they are raw, unprojected values carrying the coherent
power excess recorded in #838 (closed as not planned). Their 0.009 dB difference is one
fixture (cv06b), one bin (3.77125 GHz), an arm-to-arm difference —
NOT a bound on ``S`` — and the comparison's producer verdict was
``not_read``. What it does show is that ``S`` moves far less than the
fit, because it normalizes with the analytic Hammerstad–Jensen Z0
rather than with the fit.

So: gate on ``probe_clearance`` for the layout, on ``beta_railed``
for the fitted-value symptom, and treat ``Z0``/``beta`` as
UNREADABLE — not merely uncertain — when the status is
``insufficient``. ``S`` stands with the 0.009 dB caveat. The fix is
a longer uniform feed or a moved reference plane; raising
``n_probe_offset`` alone moves the probes toward the reflector.
``rfx.preflight.msl.MSL_PROBE_CLEARANCE_EFFECT`` is the single text
every warning about this condition embeds.

settling_db : (n_ports,) float, optional Per-drive pole-tail amplitude share in dB over modal voltage, loop-current, and witness-probe records at the extraction bins. S_raw : (n_ports, n_ports, n_freqs) complex, optional The S-matrix exactly as extracted, BEFORE passivity projection. None on the default path, where S already IS that matrix; set only when enforce_passivity=True clipped something, so no information is discarded by enforcing the bound. passivity_correction : (n_freqs,) float, optional Per-frequency amount clipped by the passivity projection: max(sigma_max(S_raw(f)) - 1, 0). None unless enforce_passivity=True actually clipped a bin — it records what the projection REMOVED, so on the default path there is nothing for it to record and sigma_max_excess is the field to read. sigma_max_excess : (n_freqs,) float, optional Per-frequency max(sigma_max(S(f)) - 1, 0) of the RAW extraction, measured before any opt-in projection and filled on every concrete call (None only while tracing, which has no value to take singular values of). Zero where the extraction was already passive. This is the honesty metric, and it does not depend on enforce_passivity: a bin with a large excess is a measurement artifact (check reliable and settling_db for the cause), whether the returned S carries that excess or a projection clipped it away. Where a projection did clip, passivity_correction equals this on the touched bins.

Near neighbour, different quantity: the ``passivity_excess`` key of
`rfx.io.network_quality_metrics()` is a single SCALAR over the
whole sweep, measured in POWER (``max(sigma_max^2 - 1, 0)``). This
field is per frequency and in amplitude. The two names differ so
the numbers are not read as interchangeable; do not compare them.

port_names : tuple[str, ...] assembly : str, optional Which rule produced S — "multi_drive_solve" (normal: S = B·A⁻¹ over all drives, issue #507) or "single_ratio_fallback". The fallback is taken when the solve returns non-finite entries on a degenerate drive system; it is the SUPERSEDED per-column rule S[j, d] = b_j / a_d, which reports a passive port's echo as the driven port's own reflection whenever that port is not matched.

**Read this before trusting a fallback result.** The fallback's
characteristic symptom is column power above 1, which the default
``enforce_passivity=False`` leaves standing in ``S`` and
``sigma_max_excess`` measures. Under ``enforce_passivity=True``
the symptom is clipped out of ``S`` — but it is not erased from
the result: ``passivity_correction`` records how much was clipped
and ``S_raw`` keeps the unprojected matrix, and the run also emits
both a fallback warning and a passivity-guard
warning. So a fallback is not silent; this field is simply the
*specific* signal. Column power above 1 has several causes (an
under-settled record, a standing-wave null, a mis-scaled current) and
only one of them is the fallback — that is what this field
disambiguates. ``None`` while tracing (the finiteness test cannot run
on a tracer, so the solve result is taken as-is — see ``cond_a``).

cond_a : (n_freqs,) float, optional Per-frequency condition number of the power-wave drive matrix A (one common row scale cancels from this condition number). Bounds DEGENERACY of the drive system only — it is not a reliability or accuracy score, and a low value does not certify the result (same contract as the coax lane's rfx.sources.coaxial_port.solve_two_port_from_wave_amplitudes()). None while tracing. beta_railed : (n_ports, n_freqs) bool, optional Per-port β-scan rail mask for the FITTED numbers (issue #681). beta_railed[p, k] is True when port p's own-drive N-probe β scan failed to bracket its residual optimum at bin k — the raw argmin sat at an edge node of the ±35% window around the analytic Hammerstad-Jensen guess, or the refined β landed within half a grid step of a window limit. Z0[p, k] (and beta[k] for p = 0) at such a bin is the scan-window limit, NOT a measurement — do not quote it. Fitted Z0/beta do not enter S11/S21, which use the analytic Z0 anchor and measured V/I. This separation does not certify the V/I or reference impedance: shared record contamination can affect both the fit and S. The own-drive diagonal is exactly the provenance of every fitted number this result carries (Z0[i, :] comes from port i's own driven run). None while tracing.

MSLSMatrixResult( S: numpy.ndarray, freqs: numpy.ndarray, Z0: numpy.ndarray, beta: numpy.ndarray, port_names: tuple[str, ...] = (), reliable: numpy.ndarray | None = None, settling_db: numpy.ndarray | None = None, S_raw: numpy.ndarray | None = None, passivity_correction: numpy.ndarray | None = None, assembly: str | None = None, cond_a: numpy.ndarray | None = None, beta_railed: numpy.ndarray | None = None, probe_clearance: tuple[MSLProbeClearance, ...] | None = None, reference_impedances: numpy.ndarray | None = None, sigma_max_excess: numpy.ndarray | None = None, settling_witness: tuple | None = None, diagnostics: tuple[Diagnostic, ...] = ())
S: numpy.ndarray
freqs: numpy.ndarray
Z0: numpy.ndarray
beta: numpy.ndarray
port_names: tuple[str, ...] = ()
reliable: numpy.ndarray | None = None
settling_db: numpy.ndarray | None = None
S_raw: numpy.ndarray | None = None
passivity_correction: numpy.ndarray | None = None
assembly: str | None = None
cond_a: numpy.ndarray | None = None
beta_railed: numpy.ndarray | None = None
probe_clearance: tuple[MSLProbeClearance, ...] | None = None
reference_impedances: numpy.ndarray | None = None
sigma_max_excess: numpy.ndarray | None = None
settling_witness: tuple | None = None
diagnostics: tuple[Diagnostic, ...] = ()
@dataclass
class MixedSMatrixResult:

Mixed-family S-matrix result (issue #488, lumped/wire + MSL v1).

S here is in the Kurokawa power-wave convention (every wave amplitude divided by sqrt(Re(Z0_port))): with unequal reference impedances across families a pseudo-wave b/a ratio is off by sqrt(Z_j/Z_i) (issue #460), so the mixed lane normalizes to power waves by construction. Off-diagonal PHASE across families is provisional — the lumped/wire cell and the MSL de-embedded plane are different reference-plane conventions (magnitude is the validated observable).

Attributes

S : (n_ports, n_ports, n_freqs) complex Power-wave S-matrix; S[i, j] = response at port i driving port j. Port order: lumped/wire ports (registration order), then MSL ports (registration order). freqs : (n_freqs,) float Frequency grid in Hz. port_names : tuple of str Combined port names (lumped/wire ports are auto-named "lw<k>"; MSL ports keep their registered names). port_families : tuple of str "lumped" / "wire" / "msl" per port, same order. z0_ref : (n_ports,) float Power-wave reference impedance per port: the registered port impedance for lumped/wire, analytic Hammerstad-Jensen Z0 for MSL. settling_db : (n_ports,) float, optional Per-drive pole-tail amplitude share in dB over recorded S channels and witness probes at the extraction bins. s21_power_witness : np.ndarray | None (n_msl, n_lw, n_freqs) real — extractor-independent |S21| cross-check for lumped/wire-driven columns: the MSL arriving power-wave magnitude over |a_drive| reconstructed from the delivered power P_del = 0.5*Re(Z_in)*|I|^2 and (1 - |S_jj|^2) (does not trust the port-cell a-wave magnitude; issue #313 triangulation). reliable : np.ndarray | None (n_msl, n_freqs) bool — relative low-signal mask from each MSL port's own driven run. A False entry is not by itself proof of an incorrect S-matrix; see MSLSMatrixResult. probe_clearance : tuple[MSLProbeClearance, ...], optional MSL-only downstream layout diagnoses, in MSL registration order. Lumped/wire ports have no entries here, as with reliable. Same contract as MSLSMatrixResult.probe_clearance, including what the insufficient status costs and what to gate on (#726): the FITTED Z0/beta go unreadable, S carries a measured 0.009 dB caveat, and reliable gates neither. beta_railed : np.ndarray | None (n_msl, n_freqs) bool — β-scan rail mask from each MSL port's own driven run, same criterion as MSLSMatrixResult.beta_railed (issue #681). In this lane the N-probe fit is DIAGNOSTIC ONLY (the |Zc| deviation warning); a True bin means that diagnostic's β/|Zc| are the scan-window limit, not a measurement. Fitted beta/Z0 do not enter S, but this does not certify their shared measured V/I. S_raw / passivity_correction : As in MSLSMatrixResult — set only when the passivity projection touched at least one bin.

MixedSMatrixResult( S: numpy.ndarray, freqs: numpy.ndarray, port_names: tuple[str, ...], port_families: tuple[str, ...], z0_ref: numpy.ndarray, settling_db: numpy.ndarray | None = None, s21_power_witness: numpy.ndarray | None = None, reliable: numpy.ndarray | None = None, S_raw: numpy.ndarray | None = None, passivity_correction: numpy.ndarray | None = None, beta_railed: numpy.ndarray | None = None, S_wave: numpy.ndarray | None = None, magnitude_channel: str = 'wave', probe_clearance: tuple[MSLProbeClearance, ...] | None = None, settling_witness: tuple | None = None, diagnostics: tuple[Diagnostic, ...] = ())
S: numpy.ndarray
freqs: numpy.ndarray
port_names: tuple[str, ...]
port_families: tuple[str, ...]
z0_ref: numpy.ndarray
settling_db: numpy.ndarray | None = None
s21_power_witness: numpy.ndarray | None = None
reliable: numpy.ndarray | None = None
S_raw: numpy.ndarray | None = None
passivity_correction: numpy.ndarray | None = None
beta_railed: numpy.ndarray | None = None
S_wave: numpy.ndarray | None = None
magnitude_channel: str = 'wave'
probe_clearance: tuple[MSLProbeClearance, ...] | None = None
settling_witness: tuple | None = None
diagnostics: tuple[Diagnostic, ...] = ()
class AD_MemoryEstimate(typing.NamedTuple):

Reverse-mode AD memory estimate (issue #30 CHECK 4 / #39).

All sizes are in gigabytes. warning is populated when the selected estimate exceeds 85% of available_gb.

ad_checkpointed_gb is the legacy jax.checkpoint(step_fn) estimate; for FDTD on the non-uniform path this is not a realistic memory number because the scan carry itself is not rematerialised (see issue #31 VESSL 369367233490). Use ad_segmented_gb for the runner-supported segmented paths: checkpoint_every on the non-uniform scan-of-scan path and checkpoint_segments on the uniform segmented-scan path. The segmented model counts segment-boundary storage (carry + cotangent, 2 × active_segments) PLUS the live-segment rematerialization tape (one segment's per-step field tape resident during backward replay, issue #277); it is minimized near sqrt(2 × n_steps) steps per segment. When present, ad_segmented_active_segments is the actual number of active segment boundaries/carry snapshots used by the segmented estimate after warmup. evidence_class labels this artifact as a static estimate so downstream tooling does not confuse it with observed profiler evidence or a bounded compiled-executable certificate.

AD_MemoryEstimate( forward_gb: float, ad_checkpointed_gb: float, ad_full_gb: float, ntff_dft_gb: float, available_gb: float | None, warning: str | None, ad_segmented_gb: float | None = None, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, ad_active_steps: int | None = None, ad_segmented_active_segments: int | None = None, grid_kind: str | None = None, grid_source: str | None = None, grid_shape: tuple[int, int, int] | None = None, sheet_gb: float | None = None)

Create new instance of AD_MemoryEstimate(forward_gb, ad_checkpointed_gb, ad_full_gb, ntff_dft_gb, available_gb, warning, ad_segmented_gb, checkpoint_every, checkpoint_segments, ad_active_steps, ad_segmented_active_segments, grid_kind, grid_source, grid_shape, sheet_gb)

forward_gb: float

Alias for field number 0

ad_checkpointed_gb: float

Alias for field number 1

ad_full_gb: float

Alias for field number 2

ntff_dft_gb: float

Alias for field number 3

available_gb: float | None

Alias for field number 4

warning: str | None

Alias for field number 5

ad_segmented_gb: float | None

Alias for field number 6

checkpoint_every: int | None

Alias for field number 7

checkpoint_segments: int | None

Alias for field number 8

ad_active_steps: int | None

Alias for field number 9

ad_segmented_active_segments: int | None

Alias for field number 10

grid_kind: str | None

Alias for field number 11

grid_source: str | None

Alias for field number 12

grid_shape: tuple[int, int, int] | None

Alias for field number 13

sheet_gb: float | None

Alias for field number 14

evidence_class: str

Evidence class label serialized with this static estimate.

def to_dict(self) -> dict[str, float | int | None | str]:

Return a stable JSON-serializable AD memory artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the estimate for research-note and CI artifacts.

class ADMemoryPlan(typing.NamedTuple):

Checkpoint planning result for reverse-mode AD memory.

checkpoint_every is the non-uniform segmented-scan chunk length. checkpoint_segments is the uniform segmented-scan segment count. checkpoint_mode is the knob selector, not a runnable-fit verdict: check full_ad_fits first, then require segmented_fits before wiring the selected segmented knob. A non-fitting plan may still carry the least-memory candidate knob for diagnostics. fit_safety_factor records the conservative multiplier used before an estimate is allowed to set full_ad_fits or segmented_fits. evidence_class labels this artifact as a calibrated conservative plan, not a certificate or observed runtime profile.

ADMemoryPlan( n_steps: int, available_memory_gb: float, target_fraction: float, target_memory_gb: float, checkpoint_every: int | None, selected_estimate: ForwardRef('AD_MemoryEstimate'), full_ad_fits: bool, segmented_fits: bool, recommendation: str, checkpoint_segments: int | None = None, checkpoint_mode: str | None = None, fit_safety_factor: float = 1.0)

Create new instance of ADMemoryPlan(n_steps, available_memory_gb, target_fraction, target_memory_gb, checkpoint_every, selected_estimate, full_ad_fits, segmented_fits, recommendation, checkpoint_segments, checkpoint_mode, fit_safety_factor)

n_steps: int

Alias for field number 0

available_memory_gb: float

Alias for field number 1

target_fraction: float

Alias for field number 2

target_memory_gb: float

Alias for field number 3

checkpoint_every: int | None

Alias for field number 4

selected_estimate: AD_MemoryEstimate

Alias for field number 5

full_ad_fits: bool

Alias for field number 6

segmented_fits: bool

Alias for field number 7

recommendation: str

Alias for field number 8

checkpoint_segments: int | None

Alias for field number 9

checkpoint_mode: str | None

Alias for field number 10

fit_safety_factor: float

Alias for field number 11

evidence_class: str

Evidence class label serialized with this conservative plan.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable AD memory plan artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the plan for memory-budget and CI artifacts.

class ADMemoryComponent(typing.NamedTuple):

Named contribution to a reverse-mode AD memory explanation.

ADMemoryComponent( name: str, memory_gb: float, share_of_selected: float, kind: str, unit: str | None = None, count: int | None = None, bytes_per_unit_gb: float | None = None, explanation: str = '')

Create new instance of ADMemoryComponent(name, memory_gb, share_of_selected, kind, unit, count, bytes_per_unit_gb, explanation)

name: str

Alias for field number 0

memory_gb: float

Alias for field number 1

share_of_selected: float

Alias for field number 2

kind: str

Alias for field number 3

unit: str | None

Alias for field number 4

count: int | None

Alias for field number 5

bytes_per_unit_gb: float | None

Alias for field number 6

explanation: str

Alias for field number 7

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable component artifact.

class ADMemoryActionHint(typing.NamedTuple):

Actionable AD-memory preflight hint derived from planning evidence.

ADMemoryActionHint( code: str, severity: str, message: str, action: str, checkpoint_mode: str | None = None, checkpoint_every: int | None = None, checkpoint_segments: int | None = None, blocking: bool = False)

Create new instance of ADMemoryActionHint(code, severity, message, action, checkpoint_mode, checkpoint_every, checkpoint_segments, blocking)

code: str

Alias for field number 0

severity: str

Alias for field number 1

message: str

Alias for field number 2

action: str

Alias for field number 3

checkpoint_mode: str | None

Alias for field number 4

checkpoint_every: int | None

Alias for field number 5

checkpoint_segments: int | None

Alias for field number 6

blocking: bool

Alias for field number 7

evidence_class: str

Evidence class label serialized with this static action hint.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable action-hint artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the action hint with non-finite floats rejected.

class ADMemoryExplainabilityReport(typing.NamedTuple):

Static reverse-mode AD memory explainability artifact.

The report decomposes the selected AD estimate into named components so users can see whether field tape, segment-boundary carries, CPML/material state, or monitor state dominates the planning artifact. It is still static planning evidence, not profiler evidence or a bounded certificate.

ADMemoryExplainabilityReport( n_steps: int, strategy: str, selected_memory_gb: float, selected_memory_field: str, estimate: ForwardRef('AD_MemoryEstimate'), components: ForwardRef('tuple[ADMemoryComponent, ...]'), dominant_component: str, recommendations: tuple[str, ...])

Create new instance of ADMemoryExplainabilityReport(n_steps, strategy, selected_memory_gb, selected_memory_field, estimate, components, dominant_component, recommendations)

n_steps: int

Alias for field number 0

strategy: str

Alias for field number 1

selected_memory_gb: float

Alias for field number 2

selected_memory_field: str

Alias for field number 3

estimate: AD_MemoryEstimate

Alias for field number 4

components: tuple[ADMemoryComponent, ...]

Alias for field number 5

dominant_component: str

Alias for field number 6

recommendations: tuple[str, ...]

Alias for field number 7

evidence_class: str

Evidence class label serialized with this static explanation.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable AD explainability artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the explanation for AD-memory diagnostics.

class MeshIntelligenceReport(typing.NamedTuple):

Consolidated mesh/memory preflight summary.

This is a lightweight user-facing planning object for the "subgrid-like" non-uniform lane: it combines existing preflight advisories with cell-count and AD-memory estimates, including a uniform-fine comparator for non-uniform meshes.

MeshIntelligenceReport( grid_shape: tuple[int, int, int], cells: int, uniform_fine_shape: tuple[int, int, int], uniform_fine_cells: int, cell_savings_factor: float, min_cell_size: float, nominal_dx: float, uses_nonuniform: bool, preflight_issues: tuple[str, ...], ad_memory: ForwardRef('AD_MemoryEstimate | None'), recommendation: str)

Create new instance of MeshIntelligenceReport(grid_shape, cells, uniform_fine_shape, uniform_fine_cells, cell_savings_factor, min_cell_size, nominal_dx, uses_nonuniform, preflight_issues, ad_memory, recommendation)

grid_shape: tuple[int, int, int]

Alias for field number 0

cells: int

Alias for field number 1

uniform_fine_shape: tuple[int, int, int]

Alias for field number 2

uniform_fine_cells: int

Alias for field number 3

cell_savings_factor: float

Alias for field number 4

min_cell_size: float

Alias for field number 5

nominal_dx: float

Alias for field number 6

uses_nonuniform: bool

Alias for field number 7

preflight_issues: tuple[str, ...]

Alias for field number 8

ad_memory: AD_MemoryEstimate | None

Alias for field number 9

recommendation: str

Alias for field number 10

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable mesh/memory planning artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the report for memory-reduction evidence artifacts.

class ADMemoryPreflightReport(typing.NamedTuple):

Composite AD-memory preflight artifact.

This report composes static memory planning, static explainability, optional mesh advisories, and optional trace-time JAX saved-residual diagnostics. It is not runtime profiler evidence, XLA memory analysis, a peak-memory guarantee, a certificate, or RF validation.

ADMemoryPreflightReport( n_steps: int, available_memory_gb: float, target_fraction: float, target_memory_gb: float, fit_safety_factor: float, status: str, supported_checkpoint_mode: str | None, checkpoint_every: int | None, checkpoint_segments: int | None, full_ad_fits: bool, checkpointing_fits: bool, memory_plan: ForwardRef('ADMemoryPlan'), explainability: ForwardRef('ADMemoryExplainabilityReport'), mesh_report: ForwardRef('MeshIntelligenceReport | None'), residual_diagnostic: object | None, action_hints: ForwardRef('tuple[ADMemoryActionHint, ...]'), evidence_boundaries: tuple[str, ...], recommendation: str)

Create new instance of ADMemoryPreflightReport(n_steps, available_memory_gb, target_fraction, target_memory_gb, fit_safety_factor, status, supported_checkpoint_mode, checkpoint_every, checkpoint_segments, full_ad_fits, checkpointing_fits, memory_plan, explainability, mesh_report, residual_diagnostic, action_hints, evidence_boundaries, recommendation)

n_steps: int

Alias for field number 0

available_memory_gb: float

Alias for field number 1

target_fraction: float

Alias for field number 2

target_memory_gb: float

Alias for field number 3

fit_safety_factor: float

Alias for field number 4

status: str

Alias for field number 5

supported_checkpoint_mode: str | None

Alias for field number 6

checkpoint_every: int | None

Alias for field number 7

checkpoint_segments: int | None

Alias for field number 8

full_ad_fits: bool

Alias for field number 9

checkpointing_fits: bool

Alias for field number 10

memory_plan: ADMemoryPlan

Alias for field number 11

Alias for field number 12

mesh_report: MeshIntelligenceReport | None

Alias for field number 13

residual_diagnostic: object | None

Alias for field number 14

action_hints: tuple[ADMemoryActionHint, ...]

Alias for field number 15

evidence_boundaries: tuple[str, ...]

Alias for field number 16

recommendation: str

Alias for field number 17

evidence_class: str

Evidence class label serialized with this composite preflight.

source_evidence_classes: tuple[str, ...]

Evidence classes from nested artifacts, in stable order.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable AD-memory preflight artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the preflight report with non-finite floats rejected.

class ADCompiledMemoryCertificate(typing.NamedTuple):

Bounded compiler-memory certificate for one exact compiled executable.

The report is derived from a caller-supplied JAX compiled object's memory_analysis() result plus complete exact-scope metadata. It is not a universal peak-memory predictor, runtime profiler artifact, RF validation result, or proof that source/config digests correspond to an opaque executable.

ADCompiledMemoryCertificate( status: str, status_reason: str, available_memory_gb: float, target_fraction: float, target_memory_gb: float, compiler_reported_required_bytes: int | None, compiler_reported_required_gb: float | None, temp_size_in_bytes: int | None, argument_size_in_bytes: int | None, output_size_in_bytes: int | None, alias_size_in_bytes: int | None, temp_gb: float | None, argument_gb: float | None, output_gb: float | None, alias_gb: float | None, exact_scope: ForwardRef('Mapping[str, object] | None'), scope_status: str, scope_status_reason: str, scope_digest: str | None, config_digest: str | None, environment_digest: str | None, memory_analysis_status: str, memory_analysis_status_reason: str, jax_version: str, evidence_boundaries: tuple[str, ...], recommendations: tuple[str, ...], source_preflight: object | None = None)

Create new instance of ADCompiledMemoryCertificate(status, status_reason, available_memory_gb, target_fraction, target_memory_gb, compiler_reported_required_bytes, compiler_reported_required_gb, temp_size_in_bytes, argument_size_in_bytes, output_size_in_bytes, alias_size_in_bytes, temp_gb, argument_gb, output_gb, alias_gb, exact_scope, scope_status, scope_status_reason, scope_digest, config_digest, environment_digest, memory_analysis_status, memory_analysis_status_reason, jax_version, evidence_boundaries, recommendations, source_preflight)

status: str

Alias for field number 0

status_reason: str

Alias for field number 1

available_memory_gb: float

Alias for field number 2

target_fraction: float

Alias for field number 3

target_memory_gb: float

Alias for field number 4

compiler_reported_required_bytes: int | None

Alias for field number 5

compiler_reported_required_gb: float | None

Alias for field number 6

temp_size_in_bytes: int | None

Alias for field number 7

argument_size_in_bytes: int | None

Alias for field number 8

output_size_in_bytes: int | None

Alias for field number 9

alias_size_in_bytes: int | None

Alias for field number 10

temp_gb: float | None

Alias for field number 11

argument_gb: float | None

Alias for field number 12

output_gb: float | None

Alias for field number 13

alias_gb: float | None

Alias for field number 14

exact_scope: Optional[Mapping[str, object]]

Alias for field number 15

scope_status: str

Alias for field number 16

scope_status_reason: str

Alias for field number 17

scope_digest: str | None

Alias for field number 18

config_digest: str | None

Alias for field number 19

environment_digest: str | None

Alias for field number 20

memory_analysis_status: str

Alias for field number 21

memory_analysis_status_reason: str

Alias for field number 22

jax_version: str

Alias for field number 23

evidence_boundaries: tuple[str, ...]

Alias for field number 24

recommendations: tuple[str, ...]

Alias for field number 25

source_preflight: object | None

Alias for field number 26

evidence_class: str

Evidence class label serialized with this bounded certificate.

is_valid_certificate: bool

Whether compiler evidence and exact scope are complete.

estimate_within_budget: bool | None

Whether the JAX compiler memory estimate fits the target budget.

This reflects Compiled.memory_analysis(), a compiler estimate — not a runtime guarantee. It does not model allocator fragmentation or runtime scratch, so True does not promise the run avoids OOM. Returns None when compiler evidence or exact scope is incomplete.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable bounded-certificate artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the certificate with non-finite floats rejected.

class ADParserHealth(typing.NamedTuple):

Parser health summary for saved-residual diagnostics.

ADParserHealth( record_count: int, known_count: int, unknown_count: int, unknown_fraction: float, warnings: tuple[str, ...])

Create new instance of ADParserHealth(record_count, known_count, unknown_count, unknown_fraction, warnings)

record_count: int

Alias for field number 0

known_count: int

Alias for field number 1

unknown_count: int

Alias for field number 2

unknown_fraction: float

Alias for field number 3

warnings: tuple[str, ...]

Alias for field number 4

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable parser-health artifact.

class ADResidualGroup(typing.NamedTuple):

Grouped summary of saved residual records.

ADResidualGroup( key: str, source_kind: str, name: str | None, dtype: str | None, record_count: int, known_count: int, unknown_count: int, known_estimated_bytes: int, line_indices: tuple[int, ...])

Create new instance of ADResidualGroup(key, source_kind, name, dtype, record_count, known_count, unknown_count, known_estimated_bytes, line_indices)

key: str

Alias for field number 0

source_kind: str

Alias for field number 1

name: str | None

Alias for field number 2

dtype: str | None

Alias for field number 3

record_count: int

Alias for field number 4

known_count: int

Alias for field number 5

unknown_count: int

Alias for field number 6

known_estimated_bytes: int

Alias for field number 7

line_indices: tuple[int, ...]

Alias for field number 8

known_estimated_gb: float

Known static byte estimate in GB for parseable records in the group.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable group artifact.

class ADResidualInspection(typing.NamedTuple):

Structured artifact for JAX saved-residual inspection.

This artifact is produced from jax.ad_checkpoint.print_saved_residuals. It explains what JAX says would be saved for reverse-mode AD under the given Python function and sample arguments. It is not a runtime profile, not an XLA memory report, and not a peak-memory certificate.

ADResidualInspection( records: ForwardRef('tuple[ADResidualRecord, ...]'), raw_lines: tuple[str, ...], total_estimated_bytes: int | None, total_estimated_gb: float | None, unknown_estimate_count: int)

Create new instance of ADResidualInspection(records, raw_lines, total_estimated_bytes, total_estimated_gb, unknown_estimate_count)

records: tuple[ADResidualRecord, ...]

Alias for field number 0

raw_lines: tuple[str, ...]

Alias for field number 1

total_estimated_bytes: int | None

Alias for field number 2

total_estimated_gb: float | None

Alias for field number 3

unknown_estimate_count: int

Alias for field number 4

evidence_class: str

Evidence class label serialized with this diagnostic artifact.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable saved-residual artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the inspection artifact with non-finite floats rejected.

class ADResidualRecord(typing.NamedTuple):

One line from JAX saved-residual inspection.

estimated_bytes is derived from the printed abstract value when shape and dtype are parseable. It is a static byte count for the residual value, not allocator overhead, fragmentation, or measured peak memory.

ADResidualRecord( aval: str, source: str, dtype: str | None = None, shape: tuple[int, ...] | None = None, size: int | None = None, estimated_bytes: int | None = None, source_kind: str = 'unknown', name: str | None = None, raw_line: str = '', line_index: int | None = None)

Create new instance of ADResidualRecord(aval, source, dtype, shape, size, estimated_bytes, source_kind, name, raw_line, line_index)

aval: str

Alias for field number 0

source: str

Alias for field number 1

dtype: str | None

Alias for field number 2

shape: tuple[int, ...] | None

Alias for field number 3

size: int | None

Alias for field number 4

estimated_bytes: int | None

Alias for field number 5

source_kind: str

Alias for field number 6

name: str | None

Alias for field number 7

raw_line: str

Alias for field number 8

line_index: int | None

Alias for field number 9

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable residual record.

class ADSavedResidualDiagnosticReport(typing.NamedTuple):

rfx saved-residual diagnostic report.

The report summarizes JAX saved-residual evidence for one differentiated callable and sample argument set. It is trace-time explainability, not a runtime profiler, XLA memory report, peak-memory guarantee, certificate, or RF/gradient validation artifact.

ADSavedResidualDiagnosticReport( inspection: ForwardRef('ADResidualInspection'), top_residuals: ForwardRef('tuple[ADResidualRecord, ...]'), groups: ForwardRef('tuple[ADResidualGroup, ...]'), parser_health: ForwardRef('ADParserHealth'), known_estimated_bytes: int, known_estimated_gb: float, total_estimated_bytes: int | None, total_estimated_gb: float | None, jax_version: str, workflow: str | None, context: ForwardRef('Mapping[str, object] | None'), artifact_snapshots: ForwardRef('Mapping[str, object]'), recommendations: tuple[str, ...])

Create new instance of ADSavedResidualDiagnosticReport(inspection, top_residuals, groups, parser_health, known_estimated_bytes, known_estimated_gb, total_estimated_bytes, total_estimated_gb, jax_version, workflow, context, artifact_snapshots, recommendations)

inspection: ADResidualInspection

Alias for field number 0

top_residuals: tuple[ADResidualRecord, ...]

Alias for field number 1

groups: tuple[ADResidualGroup, ...]

Alias for field number 2

parser_health: ADParserHealth

Alias for field number 3

known_estimated_bytes: int

Alias for field number 4

known_estimated_gb: float

Alias for field number 5

total_estimated_bytes: int | None

Alias for field number 6

total_estimated_gb: float | None

Alias for field number 7

jax_version: str

Alias for field number 8

workflow: str | None

Alias for field number 9

context: Mapping[str, object] | None

Alias for field number 10

artifact_snapshots: Mapping[str, object]

Alias for field number 11

recommendations: tuple[str, ...]

Alias for field number 12

evidence_class: str

Evidence class label serialized with this rfx diagnostic report.

known_only_bytes: int

Known-byte subtotal excluding parser-unknown residual lines.

source_evidence_class: str

Evidence class of the underlying JAX saved-residual adapter.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable diagnostic report.

def to_json(self, **kwargs: object) -> str:

Serialize the diagnostic report with non-finite floats rejected.

def diagnose_ad_saved_residuals( fun: Callable[..., typing.Any], *args: Any, top_n: int = 10, workflow: str | None = None, context: Mapping[str, object] | None = None, artifacts: Mapping[str, object] | None = None, **kwargs: Any) -> ADSavedResidualDiagnosticReport:

Return an rfx diagnostic report for JAX saved residuals.

The report layers rfx-owned summaries and recommendations on top of inspect_ad_saved_residuals. It inspects fun(*args, **kwargs) and preserves exceptions from tracing the callable. Optional context and artifacts are serialized as snapshots only; they are not used to infer runtime memory fit or physics validity.

def inspect_ad_saved_residuals( fun: Callable[..., typing.Any], *args: Any, **kwargs: Any) -> ADResidualInspection:

Inspect JAX saved residuals for fun(*args, **kwargs).

The implementation captures jax.ad_checkpoint.print_saved_residuals and parses the printed abstract values into a structured, JSON-serializable artifact. Exceptions from tracing fun propagate to the caller.

Caveat: capture uses a process-global contextlib.redirect_stdout while tracing fun. It is therefore not thread-safe, and any stray print() executed by fun during tracing is captured and parsed as a residual line (it degrades to an unknown record that nulls the byte total rather than raising). Avoid printing inside fun when using this helper.

def parse_saved_residual_line( line: str, *, line_index: int | None = None) -> ADResidualRecord:

Parse one line printed by JAX saved-residual inspection.

Unknown future JAX formats are preserved as raw text with parseable fields set to None instead of being silently discarded.

class ParetoFront(typing.NamedTuple):

Pareto frontier artifact for a finite set of objective vectors.

ParetoFront( points: ForwardRef('tuple[ParetoPoint, ...]'), minimize: tuple[bool, ...], objective_names: tuple[str, ...], source_count: int, front_indices: tuple[int, ...], dominated_indices: tuple[int, ...])

Create new instance of ParetoFront(points, minimize, objective_names, source_count, front_indices, dominated_indices)

points: tuple[ParetoPoint, ...]

Alias for field number 0

minimize: tuple[bool, ...]

Alias for field number 1

objective_names: tuple[str, ...]

Alias for field number 2

source_count: int

Alias for field number 3

front_indices: tuple[int, ...]

Alias for field number 4

dominated_indices: tuple[int, ...]

Alias for field number 5

evidence_class: str

Evidence class label serialized with this design-sweep artifact.

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable Pareto-front artifact.

def to_json(self, **kwargs: object) -> str:

Serialize the Pareto-front artifact with non-finite floats rejected.

class ParetoPoint(typing.NamedTuple):

One candidate in a Pareto front.

ParetoPoint( source_index: int, id: str, objectives: tuple[float, ...], metadata: ForwardRef('Mapping[str, object] | None') = None)

Create new instance of ParetoPoint(source_index, id, objectives, metadata)

source_index: int

Alias for field number 0

id: str

Alias for field number 1

objectives: tuple[float, ...]

Alias for field number 2

metadata: Mapping[str, object] | None

Alias for field number 3

def to_dict(self) -> dict[str, object]:

Return a stable JSON-serializable Pareto point.

def pareto_front( objectives: Sequence[Sequence[float]] | numpy.ndarray, *, minimize: bool | Sequence[bool] = True, ids: Sequence[str] | None = None, objective_names: Sequence[str] | None = None, metadata: Sequence[Mapping[str, object] | None] | None = None, atol: float = 0.0) -> ParetoFront:

Build a JSON-serializable Pareto-front artifact.

def pareto_mask( objectives: Sequence[Sequence[float]] | numpy.ndarray, *, minimize: bool | Sequence[bool] = True, atol: float = 0.0) -> numpy.ndarray:

Return a boolean mask selecting non-dominated objective rows.

minimize may be one bool for all objectives or one bool per objective. Objectives marked False are treated as maximization objectives. atol raises the bar for strict improvement: after orientation a point p dominates q only if p is no worse than q on every objective (exact <=) and better by more than atol on at least one. This keeps the relation a strict partial order (transitive, no dominance cycles), so a larger atol retains more points and the front is never empty for non-empty finite input.

def weighted_scalarization( objectives: Sequence[Sequence[float]] | numpy.ndarray, weights: Sequence[float], *, minimize: bool | Sequence[bool] = True, normalize: bool = False) -> numpy.ndarray:

Return lower-is-better weighted scalarization scores.

Maximization objectives are sign-flipped before scoring. When normalize is true, each oriented objective is min-max normalized; constant objectives contribute zero after normalization.

def epsilon_constraint_mask( objectives: Sequence[Sequence[float]] | numpy.ndarray, epsilons: Mapping[int, float], *, minimize: bool | Sequence[bool] = True, atol: float = 0.0) -> numpy.ndarray:

Return points satisfying raw epsilon constraints per objective.

For minimization objectives the constraint is objective <= epsilon; for maximization objectives it is objective >= epsilon. Objective indices not present in epsilons are unconstrained.

def select_epsilon_constrained( objectives: Sequence[Sequence[float]] | numpy.ndarray, *, primary_index: int, epsilons: Mapping[int, float], minimize: bool | Sequence[bool] = True, atol: float = 0.0) -> int | None:

Select the best feasible point by one primary objective.

Returns None when no point satisfies the epsilon constraints.

def checkify_invariants( fun: ~F, *, float_checks: bool = True, index_checks: bool = False) -> Callable[..., tuple[jax._src.checkify.Error, typing.Any]]:

Return a checkified wrapper for user and optional automatic checks.

User checks are always enabled because the helpers below emit checkify.check predicates. float_checks additionally catches NaN and divide-by-zero errors inserted by JAX checkify. index_checks is opt-in because it can be noisier for code that intentionally uses masked indexing.

def check_finite(value: Any, name: str = 'value'):

Assert that all entries of value are finite inside JAX tracing.

def check_positive(value: Any, name: str = 'value', *, allow_zero: bool = False):

Assert that all entries of value are positive or non-negative.

def check_bounds( value: Any, *, lower: float | None = None, upper: float | None = None, name: str = 'value'):

Assert lower/upper bounds for an array-like value inside JAX tracing.

def check_courant_number(courant: Any, *, limit: float = 1.0, name: str = 'courant'):

Assert a finite positive Courant-like number at or below limit.

The check is inclusive (courant <= limit), matching the textbook CFL bound. The default limit=1.0 is the 1-D ceiling; pass the dimensionality-appropriate bound for the target engine (e.g. 1/sqrt(3) ~= 0.577 for 3-D FDTD) rather than relying on the default.

@dataclass(frozen=True)
class Box:

Axis-aligned box defined by two corners (meters).

Rasterization convention (read before drawing a PEC obstacle). On each axis the volume branch is half-open [lo, hi) over node coordinates: node j at y_j belongs to the box iff lo - tol <= y_j < hi - tol, with tol = NODE_TIE_REL * node_widths[j] (#1138), using each node's forward primal cell width and repeating the last width at the final node. The convention is deliberate and several paths depend on it (see _axis_mask for the issue history), but it has two consequences when a box is drawn to a nominal physical size:

  1. The hi face contributes no cell. A box whose corners both land on node planes, lo = i*dx and hi = k*dx, occupies nodes i .. k-1. Its realized extent between the first and last occupied node plane is therefore (hi - lo) - dx, one cell short of the drawn extent, and the shortfall is entirely at the hi face, so a single box is also displaced by dx/2 toward lo. Note the two faces are NOT interchangeable: lo is inclusive and hi is exclusive. Whether that per-box displacement survives in a multi-object structure depends on which face of each object is the load-bearing one — see the facing-pair discussion below, where it cancels in one case and not the other.

  2. Float arithmetic within the on-lattice band keeps the face on its node. Concrete coordinates and comparisons remain host float64, independent of jax_enable_x64 (#802). A corner drawn through m*dx, lo + N*dx, repeated addition or a graded cumulative sum can differ from its node by a few ULP. The shared 1e-9-local-cell band includes that node at lo and excludes it at hi (#1138), preserving the half-open cell count.

    Historical (pre-#802, kept because committed values were pinned to it): masks compared float32 coordinates, themselves double-rounded f32(f32(i) * f32(dx)), so a WR-90 f64 node reconstruction disagreed with production on 30 of 31 nodes by up to 1.1e-9 m and the SAME drawing recipe gave d + dx at one aperture and d + 2*dx at another, unpredictably. That scatter is gone: drawing fins to the nominal opening now costs exactly one cell (the hi-face retreat), at every aperture.

For a PEC obstacle the occupied nodes are where the tangential E is zeroed, so consequence (1) is an electrical dimension error, not a sub-cell cosmetic one. Transverse to the propagation direction — an aperture width, a guide width, anything that sets a cutoff — the electrical dimension is the span between the innermost ZEROED node planes, (n_open + 1) * dx, the same measure that reproduces the guide's own width a = cells * dx exactly (counting open nodes alone would call WR-90 22.098 mm instead of 22.86 at a/30). An independent refit of 16 committed WR-90 single-iris configurations across two meshes pins the realized transverse aperture offset to within 1/20 of a cell of that identity (measured during the stage-S3 / issue #499 review; no committed record carries the refit yet, so like the longitudinal caution below it is a recorded observation, not a number to build on).

Do not carry that identity into the propagation direction. The electrical thickness of an obstacle (an iris's extent along the guide axis) is not the zeroed-plane span: it is set by how the fields interact with the discontinuity, not by a cutoff condition. Measured on a single-iris geometry at four drawn thicknesses, the effective thickness lands consistently between t_cells * dx and (t_cells - 1) * dx, so neither integer rule is right and the residual is a fixed per-face offset rather than a discretization error that shrinks with dx. The longitudinal convention is not settled; treat it as an unknown of order half a cell, and if a comparator needs a thickness, make the sensitivity to that half cell part of the reported envelope rather than picking a rule. (Measured during the stage-S3 / issue #499 review; no committed record carries it yet, so it is stated here as a caution, not as a number to build on.) This measured effective thickness is a different quantity from a cascade comparator's electrical-length bookkeeping — issue #499's comparator deliberately draws t_c = round(t/dx) + 1 so that (t_c - 1)*dx conserves the cascade's total electrical length; that bookkeeping choice answers a different question and is not contradicted by this caution.

A facing pair is not symmetric under this rule, because its two interior faces are different kinds of corner. For fins drawn from each wall inward, the lo fin's interior face is a hi corner, which half-openness always drops, so that fin always retreats one cell. The hi fin's interior face is a lo corner, which the banded window keeps, including float dust within the on-lattice band. Historically, before that band, the realized opening was:

  • d + dx when only the lo fin retreated. The opening is then asymmetric: its centre sits dx/2 below the guide centre.
  • d + 2*dx when rounding made the hi fin retreat as well. The two retreats cancel and the opening is symmetric.

Since #802 the nominal drawing landed on the FIRST case at every aperture (measured across the WR-90 table at a/30 and a/60: excess is uniformly one cell, asymmetric): the hi fin's lo corner, being an exact node value, captured its node. #1138 also keeps a lo face a few ULP above that node from retreating. Historical (pre-#802, float32): which case you got was unpredictable — d = 12.192 mm gave d + 2*dx while 7.620 and 18.288 mm gave d + dx, and over ~99k (guide, mesh, aperture) combinations the split was 82% / 9% / 8% across d + dx / d + 2*dx / d at even parity. Shifting each interior face half a cell the wrong way (toward the metal) retreats both faces by construction rather than by luck, giving d + 2*dx deterministically at every aperture — that is the drawing case 18's blocked revision used, and it is why re-comparing that revision against oracle(d + 2*dx) collapsed every row.

In PR #480's WR-90 single-iris lane this inflated the |S11| error against an analytic mode-matching oracle by 4-6x, and because it scales with dx it is easy to misread as first-order convergence. For a resonant structure (multi-iris filter, cavity) it shifts the passband rather than widening a magnitude tolerance, so it will not look like a discretization error at all.

Recipe, two conditions, both needed:

  • Put interior corners on cell midpoints, (j + 0.5) * dx, to make node j the innermost occupied plane. Every corner value in a one-cell-wide interval selects the same footprint ((j*dx, (j+1)*dx] for a hi corner, [j*dx, (j+1)*dx) for a lo corner), and the midpoint is that interval's centre — not merely a safe nudge away from the node. That is precisely why it is immune to the float32 effects in (2), which only ever perturb a corner by a fraction of a cell.
  • Keep the metal depth an exact number of cells, i.e. for a symmetric obstacle keep (cells - d_cells) even. When it is odd, fin_depth = (cells - d_cells)//2 truncates and a symmetric opening of that width is simply not representable on the grid: the opening is one cell wider however it is drawn. That is a representability limit, not a rasterization defect.

Under both conditions the realized opening equals the nominal one exactly (measured, 100% of ~50k even-parity combinations). Then still assert the realized footprint (count the occupied node planes) against the intended one.

Odd parity is a fork, not a dead end. Symmetric fins can only realize apertures whose cell count has the parity of cells, so when the aperture you want has the wrong parity you must choose, and both options cost something:

  • change dx (or the aperture) so the parity works, which moves every other dimension on that axis; or
  • place the fins asymmetrically on purpose, accepting a known half-cell offset of the opening rather than rounding the aperture to the wrong parity — which would change the aperture itself, the quantity that sets the cutoff.

This docstring does not recommend one over the other: how much the half-cell offset costs has not been measured here, so treat it as an open trade rather than a cheap escape. What is not optional is that the offset be recorded and representable by whatever you compare against — an off-centre aperture only stays a known quantity if the oracle can model it. If your comparator assumes a centred obstacle, an intentional offset silently becomes comparator error, which is the failure mode this whole docstring exists to prevent.

A box thinner than one local cell takes a separate thin-sheet branch (a single node); the notes above apply to the volume branch only. The thin branch's node is chosen by the volume rule whenever that rule is decisive: a thin box whose half-open [lo, hi) window selects exactly one node realizes on THAT node — so a face-registered one-cell box lands on its lo-face node deterministically, not on whichever side of the exact half-cell tie the last f64 ulp of (lo+hi)*0.5 favours (#802 follow-up). Only a window with zero nodes (a sub-cell box straddling none) or several falls back to the nearest-node rule.

Box( corner_lo: tuple[float, float, float], corner_hi: tuple[float, float, float])
corner_lo: tuple[float, float, float]
corner_hi: tuple[float, float, float]
def footprint_area(self, normal):

Declared rectangle area transverse to the supplied normal axis.

def bounding_box(self):
def mask_on_coords(self, x, y, z):
def mask(self, grid: Grid) -> jax.jaxlib._jax.Array:
@dataclass(frozen=True)
class Sphere:

Sphere defined by center and radius.

Sphere(center: tuple[float, float, float], radius: float)
center: tuple[float, float, float]
radius: float
def bounding_box(self):
def mask_on_coords(self, x, y, z):
def mask(self, grid: Grid) -> jax.jaxlib._jax.Array:
@dataclass(frozen=True)
class Cylinder:

Cylinder along a given axis.

Cylinder( center: tuple[float, float, float], radius: float, height: float, axis: str = 'z')
center: tuple[float, float, float]
radius: float
height: float
axis: str = 'z'
def footprint_area(self, normal):

Area of an axial disc; other normals do not describe this film.

def bounding_box(self):
def mask_on_coords(self, x, y, z):
def mask(self, grid: Grid) -> jax.jaxlib._jax.Array:
@dataclass(frozen=True)
class PolylineWire:

Wire defined by a polyline path with constant circular cross-section.

Voxelizes by computing the distance from each grid point to the nearest line segment of the polyline. Grid points within radius of any segment are marked as inside.

Parameters

points : tuple of tuple[float, float, float] Ordered vertices in metres, e.g. ((x0,y0,z0), (x1,y1,z1), ...). radius : float Wire radius in metres. For PEC realization, a positive radius must be at least half the smallest local cell at the vertices (a volume wire). Smaller positive radii refuse; refine the mesh to resolve the wire. Zero retains the legacy lattice filament without a declared physical radius.

PolylineWire(points: tuple[tuple[float, float, float], ...], radius: float)
points: tuple[tuple[float, float, float], ...]
radius: float
def bounding_box(self):
def mask_on_coords(self, x, y, z):
def mask(self, grid: Grid) -> jax.jaxlib._jax.Array:
class CurvedPatch:

A rectangular patch curved along one axis with a given radius.

The patch is approximated as a series of flat Box segments (staircase approximation). The number of segments depends on the grid resolution dx.

Parameters

center : tuple (x, y, z) Center of the patch in 3D space. length : float Patch length along the curvature axis. width : float Patch width perpendicular to curvature. radius : float Curvature radius (larger = flatter). axis : str Axis along which the patch is curved ("x" or "y").

CurvedPatch(*, center, length, width, radius, axis='x')
center
length
width
radius
axis
def bounding_box(self):
def mask_on_coords(self, x, y, z):

Evaluate curved patch occupancy via staircase decomposition.

def mask(self, grid):
def to_staircase(self, dx: float) -> list[Box]:

Decompose curved patch into staircase boxes.

Parameters

dx : float Grid cell size. Each box is approximately one cell wide along the curvature axis.

Returns

list of Box Flat box segments approximating the curved surface.

class Via:

Via through-hole for multi-layer PCB structures.

Parameters

center : tuple (x, y) Position in the xy-plane (meters). drill_radius : float Drill hole radius (meters). Determines the width of the vertical conductor. pad_radius : float Copper pad radius (meters) on each layer boundary. layers : list of (z_bottom, z_top) PCB layer boundaries from bottom to top (meters). Each tuple defines one dielectric layer. material : str, optional Material name assigned to all generated shapes. Defaults to "pec".

Via( *, center: tuple[float, float], drill_radius: float, pad_radius: float, layers: list[tuple[float, float]], material: str = 'pec')
center
drill_radius
pad_radius
layers
material
def bounding_box(self):
def mask_on_coords(self, x, y, z):

Evaluate via occupancy — union of decomposed Box shapes.

def mask(self, grid):
def to_shapes(self) -> list[tuple[Box, str]]:

Return a list of (Box, material_name) tuples.

Creates:

  • One vertical conductor (Box) spanning from the lowest layer bottom to the highest layer top.
  • One pad (thin Box) at each unique layer boundary z-coordinate.

Pads at shared boundaries (e.g. z_top of layer 0 == z_bot of layer 1) are emitted only once.

def to_simulation_items(self) -> list[tuple[Box, str]]:

Alias for to_shapes() -- ready for Simulation.add().

class MeshShape:

Occupancy Shape backed by a watertight triangle mesh (point-in-mesh).

Construct from a file with from_file() (the common path) or wrap an already-loaded trimesh.Trimesh directly with MeshShape(mesh).

Parameters

mesh : trimesh.Trimesh An already-loaded, already-scaled/positioned watertight mesh (metres). validate : bool, default True Raise ValueError if the mesh is not watertight.

MeshShape(mesh, *, validate: bool = True)
@classmethod
def from_file( cls, path: str, *, scale: float, translate=(0.0, 0.0, 0.0)) -> MeshShape:

Load a mesh/CAD file and return a validated MeshShape.

Supported: STL / OBJ / PLY (native trimesh) and STEP / STP (via the optional cascadio OpenCASCADE backend, part of the cad extra — the issue-#358 Stage-2 "optional helper"; no native STEP kernel in rfx). STEP assemblies of several solids are concatenated into one occupancy body.

Parameters

path : str Mesh/CAD file; the extension selects the loader. scale : float REQUIRED (no unit guessing). Multiplies the LOADED vertex units to metres:

- STL/OBJ/PLY are UNITLESS (as authored) ⇒ pass your unit→m, e.g. mm ``scale=1e-3``.
- STEP/STP carry units; cascadio converts them to METRES on load, so the loaded mesh
  is already in metres ⇒ ``scale=1.0`` (unless deliberately rescaling).

translate : tuple[float, float, float] Rigid offset in metres applied AFTER scaling. Default origin.

def bounding_box(self):
def min_feature_size(self) -> float:

Thinnest bounding-box extent (metres) — a tessellation-INDEPENDENT proxy for the smallest resolvable dimension (e.g. a thin wall/plate thickness), used by the preflight under-resolution advisory. Deliberately NOT the shortest triangle edge: that tracks mesh DENSITY, not geometry, so a finely-tessellated smooth surface would cry wolf even when the part is well resolved.

def mask_on_coords(self, x, y, z):

(Nx, Ny, Nz) bool occupancy at cell centres via point-in-mesh containment.

Only cell centres inside the mesh bounding box are ray-tested (the rest are trivially outside), so cost scales with the object's footprint, not the whole domain.

Host-side only: rasterisation runs through trimesh.contains, so this cannot be traced/jitted or used as a differentiable mesh — a traced coordinate raises a clear error rather than a cryptic JAX array-conversion failure.

def mask(self, grid) -> jax.jaxlib._jax.Array:
@dataclass(frozen=True)
class PCBLayer:

Single PCB layer (copper or dielectric).

Parameters

thickness : float Layer thickness in metres. material : str Material identifier — must be a key in MATERIAL_LIBRARY (e.g. "copper", "fr4", "rogers4003c") or a known alias ("prepreg"). Users may also register custom materials on the Simulation object. name : str or None Optional human-readable label (e.g. "top", "core"). Named layers can be looked up with Stackup.get_layer_z().

PCBLayer(thickness: float, material: str = 'copper', name: str | None = None)
thickness: float
material: str = 'copper'
name: str | None = None
@dataclass
class Stackup:

PCB stackup builder.

Layers are specified bottom-to-top (index 0 is the lowest z layer). The stackup is centred at z = 0 so that z_bottom = -total_thickness / 2 and z_top = +total_thickness / 2.

Parameters

layers : sequence of PCBLayer Ordered from bottom to top.

Stackup(layers: Sequence[PCBLayer])
layers: list[PCBLayer]
total_thickness: float

Sum of all layer thicknesses (metres).

num_layers: int
def get_layer_z(self, name: str) -> tuple[float, float]:

Return (z_lo, z_hi) of the named layer.

The stackup is centred at z = 0.

Raises

KeyError If no layer with name exists.

def to_shapes( self, center_xy: tuple[float, float] = (0.0, 0.0), size_xy: tuple[float, float] = (0.02, 0.02)) -> list[tuple[Box, str]]:

Generate Box shapes for each layer.

Parameters

center_xy : (cx, cy) Centre of the board in the xy-plane (metres). size_xy : (sx, sy) Full extent of the board in x and y (metres).

Returns

list of (Box, material_name) tuples Each tuple contains the layer geometry and the resolved material name suitable for sim.add(shape, material=...).

Conductor layers are SHEETS (#931 §1.5). A 35 µm copper foil is thinner than any cell a board simulation uses, and a PEC Box with 0 < extent < one local cell is refused by the rasterizer — it is a volume declaration the lattice cannot honour. So a conductor layer is emitted as a ZERO-THICKNESS Box, the canonical sheet declaration.

The plane is the foil's DIELECTRIC INTERFACE, not its mid-plane (§4 rule 2): the face it shares with the neighbouring dielectric layer — z_hi when the dielectric is above, z_lo when it is below (z_lo when both, and the mid-plane only when neither neighbour is a dielectric). On the mid-plane the sheet sits half a foil thickness OFF the laminate, and a mesh that puts a line at both the interface and the sheet gets a 17.5 µm vacuum cell in series with the board — the #702 slot geometry, and a ~23x dt collapse on a standard 2-layer stack-up. Dielectric layers keep their finite extent, so the stack-up's z arithmetic (get_layer_z, total_thickness) is unchanged.

@classmethod
def standard_2layer( cls, substrate_thickness: float = 0.0016, substrate_material: str = 'fr4', copper_thickness: float = 3.5e-05) -> Stackup:

Standard 2-layer PCB (top copper / substrate / bottom copper).

Parameters

substrate_thickness : float Dielectric core thickness (default 1.6 mm). substrate_material : str Dielectric material name (default "fr4"). copper_thickness : float Copper foil thickness (default 35 um / 1 oz).

@classmethod
def standard_4layer( cls, core_thickness: float = 0.001, prepreg_thickness: float = 0.0002, copper_thickness: float = 3.5e-05) -> Stackup:

Standard 4-layer PCB stackup.

Layer order (bottom to top)::

bottom copper | prepreg | inner2 copper | core (FR-4) |
inner1 copper | prepreg | top copper

Parameters

core_thickness : float Central dielectric core thickness (default 1.0 mm). prepreg_thickness : float Pre-impregnated dielectric thickness (default 0.2 mm). copper_thickness : float Copper foil thickness for all layers (default 35 um / 1 oz).

def microstrip_impedance(width: float, height: float, eps_r: float) -> tuple[float, float]:

Characteristic impedance and eps_eff of a microstrip line (analysis).

Parameters

width : float Conductor (trace) width in metres. height : float Substrate (dielectric) thickness in metres. eps_r : float Substrate relative permittivity (dimensionless, >= 1).

Returns

(z0, eps_eff) : tuple of float z0 is the characteristic impedance in ohms; eps_eff is the effective relative permittivity.

Notes

Two-regime Wheeler/Hammerstad expression (Pozar §3.8, eq. 3.196):

  • w/h <= 1: z0 = eta_0 / (2*pi*sqrt(eps_eff)) <ul> <li>ln(8 h/w + w/(4 h))

  • w/h >= 1: z0 = eta_0 / (sqrt(eps_eff) <ul> <li>(w/h + 1.393 + 0.667 ln(w/h + 1.444)))
  • def microstrip_eps_eff(width: float, height: float, eps_r: float) -> float:

    Effective relative permittivity of a microstrip line (Hammerstad).

    Parameters

    width : float Conductor (trace) width in metres. height : float Substrate (dielectric) thickness in metres. eps_r : float Substrate relative permittivity (dimensionless, >= 1).

    Returns

    float Effective relative permittivity eps_eff seen by the quasi-TEM mode (1 <= eps_eff <= eps_r).

    Notes

    Uses the Hammerstad form (Pozar §3.8, eq. 3.195); the (w/h < 1) branch adds the (1 - w/h)^2 thin-line correction.

    def microstrip_width(z0: float, height: float, eps_r: float) -> float:

    Trace width that yields a target characteristic impedance (synthesis).

    Solves the inverse problem: given a desired z0 (e.g. 50 ohm), a substrate thickness, and a substrate permittivity, return the microstrip conductor width.

    Parameters

    z0 : float Target characteristic impedance in ohms (> 0). height : float Substrate (dielectric) thickness in metres. eps_r : float Substrate relative permittivity (dimensionless, >= 1).

    Returns

    float Conductor (trace) width in metres.

    Notes

    Closed-form Wheeler/Hammerstad synthesis (Pozar §3.8, eq. 3.197). A trial w/h is computed for both the narrow and wide regimes and the branch consistent with its own validity condition is selected.

    @dataclass(frozen=True)
    class GaussianPulse:

    Differentiated Gaussian pulse with center frequency f0.

    s(t) = -2 * ((t - t0) / tau) * exp(-((t - t0) / tau)^2)

    The onset truncation at t=0 leaves a nonzero time integral: over the sampled table, S = sum s(t_n)*dt ~ -amplitude * tau * exp(-cutoff^2). A soft current source deposits exactly that integral as a net static end-charge q = S/((1+loss)*dz_src) (issue #388), so the deposited DC scales as e^(-cutoff^2): raising cutoff from 3.0 to 4.5 cuts the deposited charge by e^(4.5^2 - 3^2) ~ 7.7e4 — about 5 decades. Use a higher cutoff when a static source remnant matters (e.g. the until_decay interior-energy stop on absorbing boundaries).

    Parameters

    f0 : float Center frequency (Hz). bandwidth : float Fractional bandwidth (0–1). Default 0.5. amplitude : float Peak amplitude (V/m). Default 1.0. cutoff : float Number of tau between t=0 and the pulse peak: t0 = cutoff * tau. Default 3.0 (historical value; waveform values are unchanged at the default). Higher values buy an e^(-cutoff^2) reduction of the deposited-DC integral at the cost of a longer onset (issue #388).

    GaussianPulse( f0: float, bandwidth: float = 0.5, amplitude: float = 1.0, cutoff: float = 3.0)
    f0: float
    bandwidth: float = 0.5
    amplitude: float = 1.0
    cutoff: float = 3.0
    tau: float

    Pulse width parameter.

    t0: float

    Pulse delay (cutoff*tau ensures smooth onset).

    @dataclass(frozen=True)
    class ModulatedGaussian:

    Gaussian-envelope modulated carrier (Meep-style source).

    s(t) = amplitude * sin(2π·f0·t) * exp(-((t - t0) / tau)^2)

    The carrier-centered spectrum generally reduces low-frequency/DC content relative to a differentiated Gaussian. A finite sampled pulse does not in general have an exactly zero integral, so DC-sensitive calculations should inspect the sampled spectrum and fields explicitly.

    DC suppression is set by the envelope width: the spectrum is a Gaussian centered at f0, and its value at DC relative to the carrier peak is e^(-1/bandwidth^2) — about 0.5 at bandwidth=1.2 but 0.018 at bandwidth=0.5. Wide fractional bandwidths therefore keep substantial DC drive; when the net deposited charge of a soft source matters (e.g. the until_decay interior-energy stop, issue #388), use bandwidth <= 0.4 (measured relative DC 4.0e-4 at the issue-#388 demo scale — below the run() warning's 1e-3 threshold, which bandwidth=0.5 at ~2.6e-2 still exceeds) or a GaussianPulse with a higher cutoff.

    Parameters

    f0 : float Center/carrier frequency (Hz). bandwidth : float Fractional bandwidth (0–1). Default 0.5. amplitude : float Peak amplitude. Default 1.0. cutoff : float Number of tau before the envelope peak. Controls how gradually the source turns on. Default 5.0 (Meep convention: source starts at t=0 and peaks at t = cutoff * tau). Use 3.0 for a faster onset with a slightly non-zero initial amplitude.

    ModulatedGaussian( f0: float, bandwidth: float = 0.5, amplitude: float = 1.0, cutoff: float = 5.0)
    f0: float
    bandwidth: float = 0.5
    amplitude: float = 1.0
    cutoff: float = 5.0
    tau: float
    t0: float
    @dataclass(frozen=True)
    class CWSource:

    Continuous-wave sinusoidal source with smooth ramp-up.

    Parameters

    f0 : float Frequency in Hz. amplitude : float Peak amplitude. ramp_steps : int Number of source-frequency cycles over which to apply a cosine taper onset. 0 means instant full-amplitude.

    CWSource(f0: float, amplitude: float = 1.0, ramp_steps: int = 50)
    f0: float
    amplitude: float = 1.0
    ramp_steps: int = 50
    @dataclass(frozen=True)
    class CustomWaveform:

    User-defined waveform wrapper.

    Parameters

    func : callable Function f(t: float) -> float returning the source amplitude at time t.

    CustomWaveform(func: object)
    func: object
    class WaveguidePort(typing.NamedTuple):

    Waveguide port definition.

    x_index : int Plane index along the port normal axis (legacy name kept for backwards compatibility). y_slice : (y_lo, y_hi) or None Legacy x-normal first transverse slice (y) when normal_axis='x'. z_slice : (z_lo, z_hi) or None Legacy x-normal second transverse slice (z) when normal_axis='x'. a : float Waveguide width in meters along the first local transverse axis. b : float Waveguide height in meters along the second local transverse axis. mode : (m, n) Mode indices. (1, 0) for TE10 dominant mode. mode_type : str "TE" or "TM". Default "TE". direction : str Propagation direction when the port is driven. "+x" for a left port launching into the guide, "-x" for a right port launching back toward the guide interior. x_position : float | None Physical location (metres) of the source plane along the port normal axis. If omitted, helpers fall back to x_index * dx. normal_axis : {"x","y","z"} Port-normal axis. Default "x" for the original straight-guide model. u_slice, v_slice : tuple[int, int] or None Generic transverse aperture slices used for non-x-normal ports.

    CELL spans, not node spans: these index the FIELD ARRAYS through
    `_plane_indexer()`, so ``hi - lo`` is the number of aperture cells
    the transverse mode operator is built on and ``sum(widths)`` over the
    slice must equal ``a`` (resp. ``b``). A node span — which is what
    ``rfx/api/_compile.py::_range_to_slice`` and
    ``rfx/runners/nonuniform.py::_range_to_slice_nu`` produce, both
    reporting the physical aperture as ``(hi - lo - 1) * d`` — has ONE
    ENTRY TOO MANY and must be converted at the call site (issue #868:
    unconverted, it put ``f_cutoff`` on a guide one cell wider than the
    walls make, 10 % low at ``dx = a/9`` and 2.7 % low at ``dx = a/36``).
    
    WaveguidePort( x_index: int, y_slice: tuple[int, int] | None, z_slice: tuple[int, int] | None, a: float, b: float, mode: tuple[int, int] = (1, 0), mode_type: str = 'TE', direction: str = '+x', x_position: float | None = None, normal_axis: str = 'x', u_slice: tuple[int, int] | None = None, v_slice: tuple[int, int] | None = None)

    Create new instance of WaveguidePort(x_index, y_slice, z_slice, a, b, mode, mode_type, direction, x_position, normal_axis, u_slice, v_slice)

    x_index: int

    Alias for field number 0

    y_slice: tuple[int, int] | None

    Alias for field number 1

    z_slice: tuple[int, int] | None

    Alias for field number 2

    a: float

    Alias for field number 3

    b: float

    Alias for field number 4

    mode: tuple[int, int]

    Alias for field number 5

    mode_type: str

    Alias for field number 6

    direction: str

    Alias for field number 7

    x_position: float | None

    Alias for field number 8

    normal_axis: str

    Alias for field number 9

    u_slice: tuple[int, int] | None

    Alias for field number 10

    v_slice: tuple[int, int] | None

    Alias for field number 11

    class WaveguidePortConfig(typing.NamedTuple):

    Compiled waveguide port config for time-stepping.

    WaveguidePortConfig( x_index: int, ref_x: int, probe_x: int, y_lo: int, y_hi: int, z_lo: int, z_hi: int, normal_axis: str, u_lo: int, u_hi: int, v_lo: int, v_hi: int, e_u_component: str, e_v_component: str, h_u_component: str, h_v_component: str, ey_profile: ForwardRef('jnp.ndarray'), ez_profile: ForwardRef('jnp.ndarray'), hy_profile: ForwardRef('jnp.ndarray'), hz_profile: ForwardRef('jnp.ndarray'), mode_type: str, direction: str, f_cutoff: float, a: float, b: float, dx: float, dt: float, u_widths: ForwardRef('jnp.ndarray'), v_widths: ForwardRef('jnp.ndarray'), source_x_m: float, reference_x_m: float, probe_x_m: float, dft_total_steps: int, src_amp: float, src_t0: float, src_tau: float, src_fcen: float, waveform: str, freqs: ForwardRef('jnp.ndarray'), v_probe_t: ForwardRef('jnp.ndarray'), v_ref_t: ForwardRef('jnp.ndarray'), i_probe_t: ForwardRef('jnp.ndarray'), i_ref_t: ForwardRef('jnp.ndarray'), v_inc_t: ForwardRef('jnp.ndarray'), n_steps_recorded: ForwardRef('jnp.ndarray'), e_inc_table: ForwardRef('jnp.ndarray'), h_inc_table: ForwardRef('jnp.ndarray'), aperture_dA: ForwardRef('jnp.ndarray') = Array([], shape=(0, 0), dtype=float32), h_offset: tuple[float, float] = (0.0, 0.0), mode_indices: tuple[int, int] = (0, 0))

    Create new instance of WaveguidePortConfig(x_index, ref_x, probe_x, y_lo, y_hi, z_lo, z_hi, normal_axis, u_lo, u_hi, v_lo, v_hi, e_u_component, e_v_component, h_u_component, h_v_component, ey_profile, ez_profile, hy_profile, hz_profile, mode_type, direction, f_cutoff, a, b, dx, dt, u_widths, v_widths, source_x_m, reference_x_m, probe_x_m, dft_total_steps, src_amp, src_t0, src_tau, src_fcen, waveform, freqs, v_probe_t, v_ref_t, i_probe_t, i_ref_t, v_inc_t, n_steps_recorded, e_inc_table, h_inc_table, aperture_dA, h_offset, mode_indices)

    x_index: int

    Alias for field number 0

    ref_x: int

    Alias for field number 1

    probe_x: int

    Alias for field number 2

    y_lo: int

    Alias for field number 3

    y_hi: int

    Alias for field number 4

    z_lo: int

    Alias for field number 5

    z_hi: int

    Alias for field number 6

    normal_axis: str

    Alias for field number 7

    u_lo: int

    Alias for field number 8

    u_hi: int

    Alias for field number 9

    v_lo: int

    Alias for field number 10

    v_hi: int

    Alias for field number 11

    e_u_component: str

    Alias for field number 12

    e_v_component: str

    Alias for field number 13

    h_u_component: str

    Alias for field number 14

    h_v_component: str

    Alias for field number 15

    ey_profile: jax.jaxlib._jax.Array

    Alias for field number 16

    ez_profile: jax.jaxlib._jax.Array

    Alias for field number 17

    hy_profile: jax.jaxlib._jax.Array

    Alias for field number 18

    hz_profile: jax.jaxlib._jax.Array

    Alias for field number 19

    mode_type: str

    Alias for field number 20

    direction: str

    Alias for field number 21

    f_cutoff: float

    Alias for field number 22

    a: float

    Alias for field number 23

    b: float

    Alias for field number 24

    dx: float

    Alias for field number 25

    dt: float

    Alias for field number 26

    u_widths: jax.jaxlib._jax.Array

    Alias for field number 27

    v_widths: jax.jaxlib._jax.Array

    Alias for field number 28

    source_x_m: float

    Alias for field number 29

    reference_x_m: float

    Alias for field number 30

    probe_x_m: float

    Alias for field number 31

    dft_total_steps: int

    Alias for field number 32

    src_amp: float

    Alias for field number 33

    src_t0: float

    Alias for field number 34

    src_tau: float

    Alias for field number 35

    src_fcen: float

    Alias for field number 36

    waveform: str

    Alias for field number 37

    freqs: jax.jaxlib._jax.Array

    Alias for field number 38

    v_probe_t: jax.jaxlib._jax.Array

    Alias for field number 39

    v_ref_t: jax.jaxlib._jax.Array

    Alias for field number 40

    i_probe_t: jax.jaxlib._jax.Array

    Alias for field number 41

    i_ref_t: jax.jaxlib._jax.Array

    Alias for field number 42

    v_inc_t: jax.jaxlib._jax.Array

    Alias for field number 43

    n_steps_recorded: jax.jaxlib._jax.Array

    Alias for field number 44

    e_inc_table: jax.jaxlib._jax.Array

    Alias for field number 45

    h_inc_table: jax.jaxlib._jax.Array

    Alias for field number 46

    aperture_dA: jax.jaxlib._jax.Array

    Alias for field number 47

    h_offset: tuple[float, float]

    Alias for field number 48

    mode_indices: tuple[int, int]

    Alias for field number 49

    class CoaxialPort(typing.NamedTuple):

    SMA-style coaxial probe port for cavity coupling.

    Models: outer conductor (PEC cylinder), PTFE dielectric fill, center pin (PEC rod), lumped excitation gap at the base.

    Parameters

    position : (x, y, z) in metres Center of port on the cavity wall (gap cell location). face : str Which wall the port passes through: "top", "bottom", "front", "back", "left", or "right". pin_length : float Protrusion of center pin into the cavity (metres). Default 5 mm. pin_radius : float Center pin radius (metres). Default 0.635 mm (SMA standard). outer_radius : float Outer conductor radius (metres). Default 2.055 mm (SMA standard). impedance : float Port reference impedance in ohms. Default 50. excitation : object Source waveform (GaussianPulse, ModulatedGaussian, etc.).

    CoaxialPort( position: tuple, face: str, pin_length: float, pin_radius: float, outer_radius: float, impedance: float, excitation: object, terminates: tuple = ())

    Create new instance of CoaxialPort(position, face, pin_length, pin_radius, outer_radius, impedance, excitation, terminates)

    position: tuple

    Alias for field number 0

    face: str

    Alias for field number 1

    pin_length: float

    Alias for field number 2

    pin_radius: float

    Alias for field number 3

    outer_radius: float

    Alias for field number 4

    impedance: float

    Alias for field number 5

    excitation: object

    Alias for field number 6

    terminates: tuple

    Alias for field number 7

    @dataclass(frozen=True)
    class FloquetPort:

    Floquet port for periodic structure excitation and mode extraction.

    For a unit cell with periodic BC, the Floquet port injects a plane wave at a specified scan angle and extracts reflected and transmitted Floquet modes.

    Parameters

    position : float Position along the propagation axis (z typically), in metres. axis : str Normal axis of the port plane ("x", "y", or "z"). scan_theta : float Scan angle theta from broadside (degrees). Default 0 (broadside). scan_phi : float Scan angle phi in the xy-plane (degrees). Default 0. polarization : str Polarization: "te" or "tm". Default "te". n_modes : int Number of Floquet modes to extract (default 1 = specular only). freqs : array-like or None Analysis frequencies (Hz). If None, auto-generated. n_freqs : int Number of frequencies if freqs is None. f0 : float or None Source center frequency. If None, uses freq_max / 2. bandwidth : float Source bandwidth. Default 0.5. amplitude : float Source amplitude. Default 1.0.

    FloquetPort( position: float, axis: str = 'z', scan_theta: float = 0.0, scan_phi: float = 0.0, polarization: str = 'te', n_modes: int = 1, freqs: object = None, n_freqs: int = 50, f0: float | None = None, bandwidth: float = 0.5, amplitude: float = 1.0)
    position: float
    axis: str = 'z'
    scan_theta: float = 0.0
    scan_phi: float = 0.0
    polarization: str = 'te'
    n_modes: int = 1
    freqs: object = None
    n_freqs: int = 50
    f0: float | None = None
    bandwidth: float = 0.5
    amplitude: float = 1.0
    class RISUnitCell:

    RIS unit cell simulation wrapper.

    Automates: periodic BC + Floquet port + sweep bias/geometry to produce reflection phase / amplitude curves.

    Parameters

    cell_size : (Lx, Ly) in metres Unit cell lateral dimensions. substrate_thickness : float Substrate thickness in metres. substrate_material : str Substrate material name (must be in MATERIAL_LIBRARY or registered as custom). Default "fr4". freq_range : (f_min, f_max) in Hz Frequency range for analysis. n_freqs : int Number of frequency points. Default 21. scan_angles : list of (theta, phi) or None Default scan angles for characterisation. If None, broadside only. polarization : str "te" or "tm". Default "te". n_steps : int Number of FDTD timesteps per simulation. Default 300. dx : float or None Uniform cell size. Required when a varactor is added; otherwise auto-computed if None. cpml_layers : int CPML layers on the port-normal axis. Default 8. ground_plane : bool Whether to include a PEC ground plane below the substrate. Default True (standard RIS backed by metal).

    RISUnitCell( *, cell_size: tuple[float, float], substrate_thickness: float, substrate_material: str = 'fr4', freq_range: tuple[float, float], n_freqs: int = 21, scan_angles: list[tuple[float, float]] | None = None, polarization: str = 'te', n_steps: int = 300, dx: float | None = None, cpml_layers: int = 8, ground_plane: bool = True)
    def add_element( self, shape: Box, material: str = 'pec') -> RISUnitCell:

    Add a conducting element (patch, cross, etc.) to the unit cell.

    Parameters

    shape : Box Geometry of the element. Coordinates are in the unit cell frame: x in [0, Lx], y in [0, Ly], z relative to the substrate top surface. material : str Material name. Default "pec".

    def add_varactor( self, position: tuple[float, float], capacitance_range: tuple[float, float], component: str = 'ez') -> RISUnitCell:

    Add a tunable varactor for reconfigurability.

    The varactor is modelled as a lumped capacitor bridging a gap at the given (x, y) position on the substrate surface.

    Parameters

    position : (x, y) in metres Location in the unit cell plane. capacitance_range : (C_min, C_max) in farads Tuning range of the varactor diode. component : str E-field component the varactor bridges. Default "ez".

    def sweep_capacitance( self, values: Sequence[float], freq: numpy.ndarray | None = None) -> RISSweepResult:

    Sweep varactor capacitance and extract reflection phase/amplitude.

    Parameters

    values : sequence of float Capacitance values in farads to sweep over. freq : array or None Frequency points. If None, auto-generated from freq_range.

    Returns

    RISSweepResult

    def sweep_angle( self, theta_values: Sequence[float], freq: numpy.ndarray | None = None, phi: float = 0.0) -> RISSweepResult:

    Sweep scan angle and extract reflection vs angle.

    Parameters

    theta_values : sequence of float Scan angles theta in degrees to sweep over. freq : array or None Frequency points. If None, auto-generated from freq_range. phi : float Azimuth angle in degrees. Default 0.

    Returns

    RISSweepResult

    def plot_phase_diagram(self, result: RISSweepResult) -> object:

    Plot reflection phase vs frequency vs bias state.

    Parameters

    result : RISSweepResult Output of sweep_capacitance or sweep_angle.

    Returns

    matplotlib Figure

    @dataclass
    class RISSweepResult:

    Structured result from an RIS parameter sweep.

    Attributes

    phases : (n_params, n_freqs) ndarray Reflection phase in degrees. amplitudes : (n_params, n_freqs) ndarray Reflection amplitude (linear, 0-1). freqs : (n_freqs,) ndarray Frequency points in Hz. capacitances : (n_params,) ndarray or None Swept capacitance values (None for angle sweeps). angles : (n_params,) ndarray or None Swept scan angles in degrees (None for capacitance sweeps).

    RISSweepResult( phases: numpy.ndarray, amplitudes: numpy.ndarray, freqs: numpy.ndarray, capacitances: numpy.ndarray | None = None, angles: numpy.ndarray | None = None)
    phases: numpy.ndarray
    amplitudes: numpy.ndarray
    freqs: numpy.ndarray
    capacitances: numpy.ndarray | None = None
    angles: numpy.ndarray | None = None
    phase_range_deg: float

    Maximum achievable phase range across sweep at any frequency.

    def extract_waveguide_s_matrix( grid, materials, port_cfgs: list[WaveguidePortConfig], n_steps: int, *, boundary: str = 'cpml', cpml_axes: str | None = None, pec_axes: str = 'yz', periodic: tuple[bool, bool, bool] | None = None, debye: tuple | None = None, lorentz: tuple | None = None, ref_shifts: list[float] | tuple[float, float] | None = None, aniso_eps: tuple | None = None, conformal_weights: tuple | None = None, aniso_inv_eps: tuple | None = None, checkpoint_segments: int | None = None, return_settling: bool = False, settling_details: list | None = None, sheet_impedance: object | None = None, pec_edge_masks: tuple | None = None, field_dtype=None) -> jax.jaxlib._jax.Array | tuple[jax.jaxlib._jax.Array, numpy.ndarray]:

    Assemble an x-directed waveguide S-matrix via one-driven-port-at-a-time runs.

    field_dtype is forwarded to every core run; None uses float32. Modal records preserve their precision and widen for field storage. Spectra and S promote the record and frequency dtypes.

    PEC realization (#931 §1.7): interior Box(material='pec') walls reach this lane as the realized edge masks pec_edge_masks (rfx.boundaries.pec.realized_pec_edge_masks), applied per step by the shared apply_pec_edges. Until #931 the caller folded the PEC CELL mask into sigma = 1e10 instead — a fourth realization of the same geometry, and one that damped only the components indexed by the occupied cell, so a one-cell-thick wall or iris got its lower face and never its far one. An empty-guide REFERENCE run gets no interior edge masks; per-port reference geometry supplies its own.

    return_settling=True (issue #538) additionally returns the per-driven-run pole-tail amplitude witness: (S, settling_db) with settling_db shape (n_ports,) from settling_db_from_port_records() (NaN per run under tracing). Default False keeps the legacy single-array return for existing callers, and the S values are identical either way — the witness is post-processing of records the scan already produces.

    checkpoint_segments : int or None When set, enables segmented gradient checkpointing on each per-port FDTD run (forwarded to rfx.simulation.run as checkpoint=True, checkpoint_segments=K). See Simulation.compute_waveguide_s_matrix() for full semantics.

    def extract_waveguide_sparams( cfg: WaveguidePortConfig, *, ref_shift: float = 0.0, probe_shift: float = 0.0, v_ref_dft: jax.jaxlib._jax.Array | None = None, i_ref_dft: jax.jaxlib._jax.Array | None = None, v_probe_dft: jax.jaxlib._jax.Array | None = None, i_probe_dft: jax.jaxlib._jax.Array | None = None) -> tuple[jax.jaxlib._jax.Array, jax.jaxlib._jax.Array]:

    Extract (S11, S21) with optional reference-plane shifts.

    The Phase 2 cleanup (2026-04-25) removed the in-scan DFT accumulators (cfg.v_ref_dft etc.) from WaveguidePortConfig. Callers that used to read those fields off the config now must either:

    1. Pass V/I DFT spectra explicitly via the *_dft keyword args (used by direct unit-test mock paths and by callers that have precomputed the spectra outside the FDTD scan); OR
    2. Use extract_waveguide_port_waves / extract_waveguide_s_matrix which run the canonical post-scan rectangular full-record DFT on cfg.v_ref_t / cfg.i_ref_t.

    When the explicit DFTs are not provided this function falls back to the post-scan rect-DFT path on the recorded time series.

    Parameters

    ref_shift : float Metres to shift the reference-plane reporting location relative to the stored reference probe. Positive is downstream (+x), negative upstream. probe_shift : float Metres to shift the probe-plane reporting location relative to the stored probe plane. Positive is downstream (+x), negative upstream. v_ref_dft, i_ref_dft, v_probe_dft, i_probe_dft : array or None Optional precomputed V/I spectra at the reference and probe planes. When all are given the wave decomposition runs against them directly; otherwise the recorded time-series on cfg is used.

    def extract_waveguide_s11( cfg: WaveguidePortConfig, *, ref_shift: float = 0.0) -> jax.jaxlib._jax.Array:

    Extract S11 from backward/forward modal amplitudes with optional de-embedding.

    def extract_waveguide_s21( cfg: WaveguidePortConfig, dt: float = 0.0, *, ref_shift: float = 0.0, probe_shift: float = 0.0) -> jax.jaxlib._jax.Array:

    Extract S21 from forward-wave modal amplitudes with optional de-embedding.

    def extract_s_matrix_wire( grid, materials, ports: list, freqs: jax.jaxlib._jax.Array, n_steps: int | None = None, *, boundary: str = 'pec', cpml_axes: str | None = None, debye_spec: tuple[list, list[jax.jaxlib._jax.Array]] | None = None, lorentz_spec: tuple[list, list[jax.jaxlib._jax.Array]] | None = None, pec_edge_masks: object | None = None, return_vi_dump: bool = False) -> jax.jaxlib._jax.Array | rfx.probes.probes.WirePortVIReplayBundle:

    Extract full N-port S-parameter matrix for WirePort objects.

    Runs N simulations (one per excitation port). All port impedances are active in every run so that port loading is consistent.

    Parameters

    grid : Grid materials : MaterialArrays Base materials without port impedances folded in. ports : list of WirePort freqs : (n_freqs,) Hz n_steps : int or None Defaults to grid.num_timesteps(num_periods=30). boundary : "pec" or "cpml" cpml_axes : str or None: the grid's absorbing axes when omitted; subsets accepted debye_spec, lorentz_spec : optional dispersion specs return_vi_dump : bool When True, return a WirePortVIReplayBundle containing the production S-matrix plus raw midpoint-cell V/I phasors for independent replay of the current wire-port convention.

    Returns

    S or WirePortVIReplayBundle By default, returns an S-matrix. With return_vi_dump=True, the bundle also carries raw phasors and per-port wire cell counts.

    def compute_floquet_s_params( acc_inc: rfx.floquet.FloquetDFTAccumulator, acc_ref: rfx.floquet.FloquetDFTAccumulator, acc_trans: rfx.floquet.FloquetDFTAccumulator | None, dx: float, Lx: float, Ly: float, freqs: jax.jaxlib._jax.Array, theta_deg: float = 0.0, phi_deg: float = 0.0) -> dict:

    Compute Floquet S-parameters from DFT accumulators.

    Experimental. This pipeline carries an E0 contract test

    (tests/unit/ports/test_floquet_s_params_contract.py) — the first real pytest caller, a passivity check on pre-isolated pure-wave input, and a finite-grad AD smoke (the path is jnp-pure and AD-traceable) — but no accuracy validation against an external periodic-cell solver. It also presumes the input accumulators already isolate the incident / reflected / transmitted waves: a naive single-run soft-source drive does NOT (a bidirectional source leaves no pure-forward monitor plane), so |S11| from such a drive can exceed 1. Do not build accuracy claims on it. Tracking: https://github.com/bk-squared/rfx/issues/141

    Drive-isolation scoping (2026-07-05, R2-STOP): the obvious fix — a two-run empty-vs-device reference subtraction (E_scat = E_dev − E_empty at a +z-side plane, the pattern that works for waveguide/coax ports) — does NOT resolve this for a periodic Floquet cell. On an εr=4 slab vs the exact analytic Airy S11, the two-run extraction is non-physical: |S11|≈1.5 at band-center 6 GHz where Airy=0.09 (the ~1.5 extraction-error floor swamps the small true reflection) and larger blow-ups at band edges (small-incident normalization noise). TFSF one-way plane-wave sources — the clean isolation route — are explicitly barred with Floquet ports (see add_floquet_port). A real fix needs dedicated numerics (clean incident normalization + higher-order-mode filtering, or a Floquet-compatible one-way source), NOT a parameter flip; and drive-isolation is the PREREQUISITE to any external (RCWA / Meep-Bloch) accuracy validation — the external reference is moot until FDTD yields a physical |S11|≤1 on a real periodic structure.

    Uses incident, reflected, and (optionally) transmitted plane DFT data to compute S11 (reflection) and S21 (transmission).

    Parameters

    acc_inc : FloquetDFTAccumulator DFT at the source/reference plane (incident side). acc_ref : FloquetDFTAccumulator DFT at the reflection measurement plane. acc_trans : FloquetDFTAccumulator or None DFT at the transmission measurement plane (if present). dx : float Cell size. Lx, Ly : float Unit cell periods. freqs : (n_freqs,) array Frequencies. theta_deg, phi_deg : float Scan angles.

    Returns

    dict with 'S11', 'S21' (if transmission plane exists), 'freqs'.

    def compute_rcs( grid: Grid, materials: rfx.core.yee.MaterialArrays, n_steps: int, *, f0: float, bandwidth: float = 0.5, theta_inc: float = 0.0, phi_inc: float = 0.0, polarization: str = 'ez', theta_obs: jax.jaxlib._jax.Array | numpy.ndarray | None = None, phi_obs: jax.jaxlib._jax.Array | numpy.ndarray | None = None, freqs: jax.jaxlib._jax.Array | numpy.ndarray | None = None, boundary: str = 'cpml', cpml_layers: int = 8, tfsf_margin: int = 3, ntff_offset: int = 1, subtract_incident_reference: bool = False, phase_reference: tuple[float, float, float] | None = None) -> RCSResult | ScatteringResponse:

    Compute radar cross section of the scatterer defined in materials.

    Parameters

    grid : Grid Simulation grid (must already include CPML padding). materials : MaterialArrays Material arrays with the scatterer defined (e.g., PEC regions with very high conductivity or high eps_r). n_steps : int Number of FDTD timesteps. f0 : float Center frequency of the Gaussian pulse (Hz). bandwidth : float Fractional bandwidth of the pulse. theta_inc : float Incident angle in degrees (0 = +x propagation). Non-zero values tilt the illumination in the x-y plane and route through the open-domain oblique Method-B TFSF (2.5-D: open transverse y, thin-periodic z). Only the far-field PATTERN / specular direction is validated at oblique incidence (specular peak phi = 180 - theta_inc at theta_obs = 90 deg). Measured envelope on the committed gate configuration (2.2-lambda plate, 700 steps, public path, corner-inclusive kernels): peak error 0 deg at theta_inc=20 and 3 deg at theta_inc=40 (4 deg on the pre-fix exclusive kernels), gated at +/-6 deg, with a 3-7 deg peak-azimuth sensitivity to domain size at fixed dx/plate/CPML (PR #461 audit, measured on the PRE-fix exclusive kernels; not re-measured in-tree on the inclusive kernels) — i.e. the argmax azimuth of the broad specular lobe is NOT converged to ~1 deg. See tests/oracle/test_oblique_rcs_specular.py.

    ABSOLUTE oblique sigma (issue #471 F7, all numbers measured): the
    normalization is the MEASURED 1D-aux incident spectrum (see
    ``tfsf_oblique_open.measure_incident_spectrum``), which removed a
    +6.58/+4.51 dB (theta 20/40 deg) inflation the analytic normal-path
    spectrum caused. Against a PO uniform-aperture oracle on the gate
    grid (dx=2 mm = lambda/30): specular-peak Delta +0.86/+0.85 dB
    (theta 20/40), lobe-flat over +/-12 deg. The returned sigma is that
    of an aperture of height ``h_eff = (k_hi - k_lo)*dx`` of the NTFF
    z-box (span-doubling ratio 4.000 measured — sigma scales as
    h_eff^2); the oblique path's hardcoded 2-cell midplane box means
    h_eff = 2*dx, so scale by ``(L_z/h_eff)^2`` for a physical strip
    height ``L_z``. CAVEAT (measured, pinned-geometry): halving dx to
    lambda/60 moves Delta to -1.55 dB — a 2.4 dB resolution
    sensitivity, so treat absolute oblique sigma as a +/-2 dB-class
    number at lambda/30-lambda/60, NOT sub-dB (the committed test gates
    |Delta| <= 1.5 dB at the fixed gate grid as a regression lock, not
    an accuracy claim). Requires ``polarization='ez'`` and a uniform
    grid; other combos raise ``NotImplementedError`` (issue #404 arc).
    

    phi_inc : float Incident azimuth in degrees (reserved for future oblique support). polarization : str Electric field polarization: "ez" or "ey". theta_obs : array or None Observation elevation angles in radians. Default: 0 to pi, 37 points. phi_obs : array or None Observation azimuth angles in radians. Default: [0, pi/2]. freqs : array or None Frequencies at which to compute RCS (Hz). Default: [f0]. boundary : str Boundary condition type ("cpml" or "pec"). cpml_layers : int Number of CPML layers (must match grid.cpml_layers). tfsf_margin : int Cells between CPML edge and TFSF boundary. ntff_offset : int Cells between TFSF boundary and NTFF box (NTFF must be in the scattered-field region, i.e., outside the TFSF box). The default (1) places the Huygens surface deep in the reactive near field. See "Bistatic pattern caveat" below: this default is validated for the backscatter/monostatic bin, but is NOT a validated bistatic setup, and increasing it does not close the oblique gap at test scale. subtract_incident_reference : bool If True (default False), run a second vacuum (no-scatterer) pass with the identical TFSF+NTFF setup and subtract its far-field from the target's at the COMPLEX level (E_scat = E_far[target] - E_far[vacuum]) before forming the RCS. This is the standard total-field/scattered-field normalization; it removes the full incident field read by NTFF side faces inside the total-field slab, which otherwise produces a spurious forward-oblique lobe (issue #280). Doubles the solve cost. Default False keeps the validated monostatic path byte-identical; opt in for the bistatic pattern. Without phase_reference, monostatic_rcs is computed from the raw (unsubtracted) run regardless of this flag, preserving the separately checked monostatic extraction. phase_reference : (x, y, z) in metres, optional Opt in to a ScatteringResponse with complex scattering amplitudes referenced to this physical point. Requires normal +x incidence, a uniform 3-D grid with symmetric CPML, and subtract_incident_reference=True. The x coordinate must be on an E-node plane inside the total-field slab; it is never silently snapped. The normal incident wave is uniform in y/z. Only finite, positive frequencies with a nonzero incident spectrum are admitted; the caller must additionally check adequate source bandwidth and record/mesh convergence. Without this option the return is unchanged.

    Returns

    RCSResult freqs, theta, phi, rcs_dbsm (dBsm), rcs_linear (m^2), monostatic_rcs (dBsm, evaluated exactly at the true backscatter direction — for +x incidence that is (theta=pi/2, phi=pi) under the farfield r_hat convention; it does not depend on theta_obs/phi_obs). With phase_reference set, ScatteringResponse.rcs holds the RCS normalized by the incident measured at that reference, consistent with its complex F components. Frequencies are the realized NTFF bins.

    Bistatic pattern caveat

    RCSResult.monostatic_rcs is evaluated from the raw target run at exact backscatter, independently of the observation grid. Its current Mie comparison is in tests/fixtures/rcs_sphere_mie/.

    The default unsubtracted rcs_dbsm / rcs_linear bistatic pattern is NOT VALIDATED. The source's total-field slab is infinite transversely, so the side faces of a closed NTFF box record the full incident field. Increasing ntff_offset does not remove that contribution; off-axis bins cannot be interpreted as the scatterer's bistatic response.

    Set subtract_incident_reference=True for the reference-subtracted path. It doubles the solve cost and subtracts a matching vacuum run at the complex far-field level. The committed sphere comparison is in tests/fixtures/rcs280_reference_subtraction/; its scope is limited to that sphere, frequency, polarization, angle cut and discretization.

    After subtraction, refine curved surfaces, repeat with a longer run, vary NTFF placement and CPML thickness, and enlarge the domain when deep pattern nulls matter. Compare each new target and configuration with an analytic or independent reference before treating the bistatic values as quantitative.

    def compute_rcs_jax( ntff_data, box: NTFFBox, grid: Grid, theta, phi, e_inc_amplitude):

    JAX-differentiable RCS(θ,φ) from NTFF data — the AD counterpart of compute_rcs.

    compute_rcs orchestrates (TFSF setup → run → far-field → RCS) with a numpy post-processor, so it is NOT on the AD tape. This function is the differentiable POST-PROCESSING half: given ntff_data accumulated by a differentiable run(..., tfsf=…, ntff=…) (whose accumulators flow through the lax.scan AD tape w.r.t. the scatterer materials), it returns σ as a jnp array so jax.grad flows scatterer-ε → RCS. It is the primitive behind differentiable RCS-reduction / -shaping inverse design.

    Same formula as compute_rcs (σ = 4π·|E_far|²/|E_inc|², compute_far_field's E_θ/E_φ already carry the jk/4π factor so |E_θ|²+|E_φ|² is the r-scaled scattered power), evaluated with compute_far_field_jax. For the monostatic (backscatter) bin at normal +x incidence pass theta=[π/2], phi=[π].

    Parameters

    ntff_data : NTFFData NTFF surface accumulators from run(..., tfsf=…, ntff=…) (JAX arrays, on tape). box : NTFFBox grid : Grid theta, phi : arrays in radians Observation directions. Backscatter for +x incidence is (π/2, π). e_inc_amplitude : (n_freqs,) complex array Incident plane-wave spectral amplitude for normalization — source-only, hence a CONSTANT for the gradient. At normal incidence use rfx.sources.tfsf.measure_normal_incident_spectrum() (cfg, state, n_steps, freqs, dt) on the same init_tfsf config the run uses — the incident the grid carries, as compute_rcs does. The source waveform's own DFT (_incident_spectrum_amplitude) is about 1.1 dB larger than that incident and makes σ low by the same 1.1 dB (issue #820).

    Returns

    jnp.ndarray rcs_linear (n_freqs, n_theta, n_phi) in m², differentiable in ntff_data (hence in the scatterer permittivity upstream). Take 10·log10 for dBsm.

    See Also

    compute_rcs : the numpy orchestrator (Mie-validated monostatic bin). compute_far_field_jax : the differentiable far-field this builds on.

    class RCSResult(typing.NamedTuple):

    Radar cross section computation result.

    freqs : (n_freqs,) Hz theta : (n_theta,) radians phi : (n_phi,) radians rcs_dbsm : (n_freqs, n_theta, n_phi) in dBsm rcs_linear : (n_freqs, n_theta, n_phi) in m^2 monostatic_rcs : (n_freqs,) backscatter RCS in dBsm, evaluated exactly at the backscatter direction (opposite the incident propagation vector), independent of the theta/phi observation grid. For normal-incidence +x propagation this is (theta=pi/2, phi=pi), i.e. -x.

    VALIDATION SCOPE (read before trusting the full pattern)

    monostatic_rcs is compared with exact Mie in the committed tests/fixtures/rcs_sphere_mie/ PEC-sphere case. The default unsubtracted rcs_dbsm / rcs_linear bistatic pattern is NOT validated: the NTFF side faces record the full incident field inside the total-field slab, and increasing ntff_offset alone does not remove it. Results produced by compute_rcs(..., subtract_incident_reference=True) use two-run complex far-field subtraction; that path is validated only for the documented ka~1 sphere comparison. RCSResult does not record which option produced it, so retain the call configuration with archived results. See compute_rcs ("Bistatic pattern caveat") for limits and convergence checks.

    RCSResult( freqs: ForwardRef('np.ndarray'), theta: ForwardRef('np.ndarray'), phi: ForwardRef('np.ndarray'), rcs_dbsm: ForwardRef('np.ndarray'), rcs_linear: ForwardRef('np.ndarray'), monostatic_rcs: ForwardRef('np.ndarray'), settling_db: float | None = None, settling_witness: dict | None = None)

    Create new instance of RCSResult(freqs, theta, phi, rcs_dbsm, rcs_linear, monostatic_rcs, settling_db, settling_witness)

    freqs: numpy.ndarray

    Alias for field number 0

    theta: numpy.ndarray

    Alias for field number 1

    phi: numpy.ndarray

    Alias for field number 2

    rcs_dbsm: numpy.ndarray

    Alias for field number 3

    rcs_linear: numpy.ndarray

    Alias for field number 4

    monostatic_rcs: numpy.ndarray

    Alias for field number 5

    settling_db: float | None

    Alias for field number 6

    settling_witness: dict | None

    Alias for field number 7

    class ScatteringResponse(typing.NamedTuple):

    One incident-polarization column of a phase-referenced scattering matrix.

    With the exp(+j omega t) convention, the outgoing field at reference_position + R * s_hat is exp(-jkR) / R times (F_theta, F_phi) * E_inc(reference_position). F has units of metres, shape (n_freqs, n_theta, n_phi), and uses the spherical output basis of rcs.theta/rcs.phi. Run both ey and ez for two columns. incident_spectrum is the actual E-field DFT at that reference, not the source waveform. Grid/record convergence still bounds accuracy.

    ScatteringResponse( rcs: ForwardRef('RCSResult'), F_theta: ForwardRef('np.ndarray'), F_phi: ForwardRef('np.ndarray'), incident_spectrum: ForwardRef('np.ndarray'), reference_position: ForwardRef('np.ndarray'), polarization: str)

    Create new instance of ScatteringResponse(rcs, F_theta, F_phi, incident_spectrum, reference_position, polarization)

    rcs: RCSResult

    Alias for field number 0

    F_theta: numpy.ndarray

    Alias for field number 1

    F_phi: numpy.ndarray

    Alias for field number 2

    incident_spectrum: numpy.ndarray

    Alias for field number 3

    reference_position: numpy.ndarray

    Alias for field number 4

    polarization: str

    Alias for field number 5

    class DebyePole(typing.NamedTuple):

    Single Debye pole parameters.

    delta_eps : float Permittivity contribution Δε (dimensionless). tau : float Relaxation time in seconds.

    DebyePole(delta_eps: float, tau: float)

    Create new instance of DebyePole(delta_eps, tau)

    delta_eps: float

    Alias for field number 0

    tau: float

    Alias for field number 1

    class LorentzPole(typing.NamedTuple):

    Single Lorentz oscillator pole.

    omega_0 : float Resonant angular frequency (rad/s). Set to 0 for Drude. delta : float Damping coefficient (rad/s). kappa : float Coupling strength = Δε · ω₀² for Lorentz, ω_p² for Drude.

    LorentzPole(omega_0: float, delta: float, kappa: float)

    Create new instance of LorentzPole(omega_0, delta, kappa)

    omega_0: float

    Alias for field number 0

    delta: float

    Alias for field number 1

    kappa: float

    Alias for field number 2

    def drude_pole(omega_p: float, gamma: float) -> LorentzPole:

    Create a Drude pole from plasma frequency and collision rate.

    Parameters

    omega_p : float Plasma frequency (rad/s). gamma : float Collision rate (rad/s).

    def lorentz_pole( delta_eps: float, omega_0: float, delta: float) -> LorentzPole:

    Create a Lorentz pole from physical parameters.

    Parameters

    delta_eps : float Permittivity contribution Δε (dimensionless). omega_0 : float Resonant angular frequency (rad/s). delta : float Damping coefficient (rad/s).

    @dataclass(frozen=True)
    class ThinConductor:

    Thin conductor specification.

    Parameters

    shape : Shape Geometric region of the thin conductor. sigma_bulk : float Bulk conductivity (S/m). e.g. copper = 5.8e7. When >= 1e6, treated as PEC thin sheet (added to PEC mask). thickness : float Physical thickness in metres. eps_r : float Relative permittivity (default 1.0). surface_impedance_f0 : float | Array | None Band-centre frequency (Hz) for the opt-in Leontovich surface- impedance loss model (issue #669). None (default) keeps the exact legacy semantics: metal -> lossless PEC sheet, sub-threshold -> DC lossy fold sigma_bulk*t/d. When set, the sheet is a resistive sheet of sheet resistance Rs0 = leontovich_rs(f0, sigma_bulk) for ANY sigma_bulk > 0, realized as sigma_eff = 1/(Rs0*d_norm).

    ThinConductor( shape: rfx.geometry.csg.Shape, sigma_bulk: float, thickness: float, eps_r: float = 1.0, surface_impedance_f0: float | jax.jaxlib._jax.Array | None = None)
    shape: rfx.geometry.csg.Shape
    sigma_bulk: float
    thickness: float
    eps_r: float = 1.0
    surface_impedance_f0: float | jax.jaxlib._jax.Array | None = None
    is_pec: bool

    Whether this thin conductor should be treated as PEC.

    ORDER IS LOAD-BEARING: the None-check short-circuits BEFORE the float compare, so a traced (JAX tracer) sigma_bulk never reaches >= when surface_impedance_f0 is set — that is what makes sigma_bulk a legal differentiable DoF in f0 mode. Pinned by tests/unit/materials/test_thin_conductor.py (is_pec-order pin).

    r_s_leontovich

    Band-centre surface resistance Rs0 (ohms/sq); f0 mode only.

    sheet_resistance: float

    Sheet resistance R_s = 1/(σ·t) in Ω/sq.

    MATERIAL_LIBRARY = {'vacuum': {'eps_r': 1.0, 'sigma': 0.0}, 'air': {'eps_r': 1.0006, 'sigma': 0.0}, 'fr4': {'eps_r': 4.4, 'sigma': 0.025}, 'rogers4003c': {'eps_r': 3.55, 'sigma': 0.0026661876953669597}, 'rogers4350b': {'eps_r': 3.48, 'sigma': 0.007163241056973391}, 'rt_duroid_5880': {'eps_r': 2.2, 'sigma': 0.0011015235548933917}, 'alumina': {'eps_r': 9.8, 'sigma': 0.0}, 'silicon': {'eps_r': 11.9, 'sigma': 0.01}, 'ptfe': {'eps_r': 2.1, 'sigma': 0.0}, 'copper': {'eps_r': 1.0, 'sigma': 58000000.0}, 'aluminum': {'eps_r': 1.0, 'sigma': 35000000.0}, 'pec': {'eps_r': 1.0, 'sigma': 10000000000.0}, 'water_20c': {'eps_r': 4.9, 'sigma': 0.0, 'debye_poles': [DebyePole(delta_eps=74.1, tau=8.3e-12)]}}
    def realized_pec_edge_masks(cell_mask, sheets=(), wires=(), periodic=(False, False, False)):

    The one function that turns conductor geometry into PEC E edges.

    Parameters

    cell_mask : (nx, ny, nz) bool array or None Primal-cell occupancy of every PEC VOLUME (Box / Sphere / Cylinder via sim.add, and thick PolylineWire). None when the run has no volume conductor. sheets : iterable of SheetSpec PEC sheets (add_thin_conductor with PEC conductivity). wires : iterable of WireSpec PEC filaments. periodic : (bool, bool, bool) The run's per-axis periodic flags (#689). Callers on a periodic lane MUST pass their own; the default is the non-periodic convention.

    Returns

    (Mx, My, Mz) boolean arrays, grid shape: True where that E component is zeroed. Consequences the contract test pins: a Box drawn z_a -> z_b on node planes realizes walls at BOTH planes and shorts every normal edge between them; a 1-cell Box is a filled slab with two faces on every axis; a sheet is one plane with its normal edge live; the rule is invariant under mirror and axis permutation.

    def realized_wall_planes( edge_masks, axis, *, ij=None, region=None, periodic=(False, False, False)):

    Sorted node-plane indices along axis where a tangential wall exists.

    A plane k counts when some E component tangential to axis (the two components other than axis) is PEC on that plane, inside the selection:

    • region — a tuple of three slices over the grid; None is the whole grid. Any tangential edge whose index falls in the region.
    • ij — the two in-plane node indices of ONE column (in axis order, skipping axis). A wall counts on the column when any tangential edge INCIDENT to the node (ij, k) is PEC, i.e. E_t at the node's own index or at the backward index along t — so a node on the hi rim of a footprint, whose incident edges are stored at i-1, is found. The backward neighbour follows the run's #689 convention: on a periodic (or length-1) in-plane axis the backward neighbour of index 0 is n-1, so a wall on the seam node is found; on a non-periodic axis index 0 has no backward edge.

    periodic is the run's per-axis flags; callers on a periodic lane MUST pass their own (the default is the non-periodic convention). It only affects the ij= column form — region= already scans every stored index.

    ij and region are exclusive. Consumers: preflight cavity / guide-width checks, cv15 assert_realized_stack, MSL trace detection, oracles.

    EVERY plane, not just the two faces. A body three cells thick has a tangential wall on all four of its node planes, because the interior is shorted too, so this returns four indices and not two. max - min is therefore the body's OUTER span and over-reads a guide or a cavity bounded by thick walls: a 40-cell guide inside 3-cell walls gives [0, 1, 2, 3] and [43, 44, 45], and 45 - 0 is 45, not

    1. A consumer that wants the clear opening brackets the aperture between the hi plane of one wall group and the lo plane of the next (_port_transverse_spans does, and reads 40).
    def edge_is_pec(edge_masks, component, i, j, k) -> bool:

    True iff the E edge component at index (i, j, k) is PEC.

    component is "ex"/"ey"/"ez", "x"/"y"/"z" or 0/1/2. Wire-port live-cell logic (#556 end gap, #929 port_in_pec), probes and sources read this instead of scanning a cell mask.

    def load_material_csv( path: str, *, freq_col: str = 'freq_hz', eps_real_col: str = 'eps_r', eps_imag_col: str = 'eps_i', tan_delta_col: str | None = None) -> tuple[numpy.ndarray, numpy.ndarray]:

    Load measured material data from a CSV file.

    Supports two input formats:

    1. Complex permittivity: columns for frequency, real part, and imaginary part of permittivity.
    2. Loss tangent: columns for frequency, real part, and tan(delta). The imaginary part is computed as eps_r * tan_delta.

    Parameters

    path : str Path to the CSV file. freq_col : str Column name for frequency in Hz. eps_real_col : str Column name for the real part of permittivity. eps_imag_col : str Column name for the imaginary part of permittivity. tan_delta_col : str or None Column name for loss tangent. If provided, eps_imag_col is ignored and the imaginary part is derived from eps_r * tan_delta.

    Returns

    freqs : ndarray, shape (N,) Frequencies in Hz. eps_complex : ndarray, shape (N,), dtype complex128 Complex relative permittivity eps' - j*eps''.

    def fit_debye( freqs: numpy.ndarray, eps_measured: numpy.ndarray, n_poles: int = 1, *, eps_inf: float | None = None) -> DebyeFitResult:

    Fit Debye relaxation model to measured permittivity data.

    Uses scipy.optimize.minimize (L-BFGS-B) to find optimal Debye pole parameters that minimize the relative RMS error.

    Model: eps(f) = eps_inf + sum_k delta_eps_k / (1 + j2piftau_k)

    Parameters

    freqs : (N,) array Measurement frequencies in Hz. eps_measured : (N,) complex array Measured complex permittivity. n_poles : int Number of Debye poles to fit. eps_inf : float or None If provided, fix the high-frequency permittivity. If None, it is included as a free parameter (initialized from the highest-frequency measurement).

    Returns

    DebyeFitResult Named tuple with eps_inf, poles (list of DebyePole), and fit_error (relative RMS).

    def fit_lorentz( freqs: numpy.ndarray, eps_measured: numpy.ndarray, n_poles: int = 1, *, eps_inf: float | None = None) -> LorentzFitResult:

    Fit Lorentz oscillator model to measured permittivity data.

    Uses scipy.optimize.minimize (L-BFGS-B) to find optimal Lorentz pole parameters.

    Model: eps(f) = eps_inf + sum_k delta_eps_k * omega_k^2 / (omega_k^2 - omega^2 + jomegagamma_k)

    Parameters

    freqs : (N,) array Measurement frequencies in Hz. eps_measured : (N,) complex array Measured complex permittivity. n_poles : int Number of Lorentz poles to fit. eps_inf : float or None If provided, fix the high-frequency permittivity. If None, it is included as a free parameter.

    Returns

    LorentzFitResult Named tuple with eps_inf, poles (list of LorentzPole), and fit_error (relative RMS).

    def eval_debye( freqs: numpy.ndarray, eps_inf: float, poles: list[DebyePole]) -> numpy.ndarray:

    Evaluate a Debye model at given frequencies.

    Parameters

    freqs : (N,) array in Hz eps_inf : high-frequency permittivity poles : list of DebyePole

    Returns

    eps_complex : (N,) complex array

    def eval_lorentz( freqs: numpy.ndarray, eps_inf: float, poles: list[LorentzPole]) -> numpy.ndarray:

    Evaluate a Lorentz or Drude pole model at given frequencies.

    Parameters

    freqs : (N,) array in Hz eps_inf : high-frequency permittivity poles : list of LorentzPole (lorentz_pole or drude_pole; a Drude pole has omega_0 == 0 and gives eps_inf - omega_p^2 / (w^2 - jgammaw))

    Returns

    eps_complex : (N,) complex array

    def plot_material_fit( freqs: numpy.ndarray, eps_measured: numpy.ndarray, eps_fitted: numpy.ndarray, *, ax: object | None = None) -> object:

    Plot measured vs fitted complex permittivity.

    Creates a two-panel plot showing real and imaginary parts of permittivity as a function of frequency.

    Parameters

    freqs : (N,) array Frequencies in Hz. eps_measured : (N,) complex array Measured permittivity. eps_fitted : (N,) complex array Fitted permittivity from the model. ax : matplotlib Axes or None If None, a new figure with two subplots is created.

    Returns

    fig : matplotlib Figure

    class DebyeFitResult(typing.NamedTuple):

    Result of a Debye pole fit.

    eps_inf : float High-frequency permittivity (dimensionless). poles : list[DebyePole] Fitted Debye poles. fit_error : float Relative RMS fit error.

    DebyeFitResult(eps_inf: float, poles: list, fit_error: float)

    Create new instance of DebyeFitResult(eps_inf, poles, fit_error)

    eps_inf: float

    Alias for field number 0

    poles: list

    Alias for field number 1

    fit_error: float

    Alias for field number 2

    class LorentzFitResult(typing.NamedTuple):

    Result of a Lorentz pole fit.

    eps_inf : float High-frequency permittivity (dimensionless). poles : list[LorentzPole] Fitted Lorentz poles. fit_error : float Relative RMS fit error.

    LorentzFitResult(eps_inf: float, poles: list, fit_error: float)

    Create new instance of LorentzFitResult(eps_inf, poles, fit_error)

    eps_inf: float

    Alias for field number 0

    poles: list

    Alias for field number 1

    fit_error: float

    Alias for field number 2

    def differentiable_material_fit( sim_factory: Callable, s_measured: numpy.ndarray, freqs: numpy.ndarray, n_debye_poles: int = 1, n_lorentz_poles: int = 0, *, n_iterations: int = 50, learning_rate: float = 0.01, initial_guess=None, weight_mag: float = 1.0, weight_phase: float = 0.1, checkpoint: bool = True, verbose: bool = True, normalization: str = 'per_probe_max', reference_probe: int | None = None) -> MaterialFitResult:

    Fit dispersive material poles to S-parameter data via jax.grad through FDTD.

    Parameters

    sim_factory : callable(eps_inf, debye_poles, lorentz_poles) -> Simulation Factory that builds a Simulation with the given material parameters. The fixture geometry, ports, and probes are set inside this callable. It is called once outside the gradient tape for static setup and again with traced poles for each loss evaluation. Declarations not carried by the fit's assembly are refused before FDTD (#1290), including wire/passive/MSL/modal ports, plain/TFSF sources, lumped RLC, periodic overrides, Kerr, conformal walls, point ports on PEC edges, and non-default solver, precision, stencil or interface settings. Refinements, non-uniform meshes, and surface-impedance sheets retain their existing refusals. s_measured : (n_ports, n_ports, n_freqs) complex array Measured S-parameter matrix. freqs : (n_freqs,) array in Hz Frequency points matching s_measured. n_debye_poles : int Number of Debye poles to fit. n_lorentz_poles : int Number of Lorentz poles to fit. n_iterations : int Number of Adam optimization iterations. learning_rate : float Adam learning rate. initial_guess : DebyeFitResult, LorentzFitResult, or None Initial pole parameters from fit_debye / fit_lorentz. If None, a frequency-band-based default is used. weight_mag, weight_phase : float Weights for magnitude and phase in the S-parameter loss. checkpoint : bool Use jax.checkpoint in the FDTD loop (recommended). verbose : bool Print progress. normalization : {"per_probe_max", "reference_probe"} Loss-proxy normalization mode (issue #580; see the module docstring's ACCURACY CAVEAT for when each is appropriate). "per_probe_max" (default, legacy) is shape-only and BLIND to magnitude-acting parameters such as conductivity; "reference_probe" divides every probe spectrum, complex and per-frequency, by the designated reference probe's spectrum, so magnitude and inter-probe level enter the loss while the shared drive amplitude cancels. reference_probe : int or None Required (and only used) with normalization="reference_probe": index into the fixture's registered probe list of the probe that records the reference (incident-side) signal. Must not be one of the first n_ports probes — those are the response probes the proxy is built from, and self-referencing would reintroduce the degeneracy this mode exists to remove.

    Returns

    MaterialFitResult final_s_params carries the LAST iteration's model-side proxy spectrum (same normalization as the loss) — previously this field was always None.

    @dataclass
    class MaterialFitResult:

    Result of differentiable material fitting.

    Attributes

    eps_inf : float High-frequency permittivity. debye_poles : list[DebyePole] Fitted Debye poles (may be empty). lorentz_poles : list[LorentzPole] Fitted Lorentz poles (may be empty). loss_history : list[float] Loss value at each iteration. final_s_params : np.ndarray or None S-parameters from the final simulation. freqs : np.ndarray Frequency array in Hz. converged : bool Whether the optimizer converged (loss plateau).

    MaterialFitResult( eps_inf: float, debye_poles: list = <factory>, lorentz_poles: list = <factory>, loss_history: list = <factory>, final_s_params: numpy.ndarray | None = None, freqs: numpy.ndarray | None = None, converged: bool = False)
    eps_inf: float
    debye_poles: list
    lorentz_poles: list
    loss_history: list
    final_s_params: numpy.ndarray | None = None
    freqs: numpy.ndarray | None = None
    converged: bool = False
    def sparam_loss( s_sim: jax.jaxlib._jax.Array, s_meas: jax.jaxlib._jax.Array, *, weight_mag: float = 1.0, weight_phase: float = 0.1) -> jax.jaxlib._jax.Array:

    Weighted MSE loss between simulated and measured S-parameters.

    Parameters

    s_sim, s_meas : (..., n_freqs) complex arrays S-parameter arrays (any leading shape, typically (n_ports, n_ports, n_freqs)). weight_mag : float Weight on magnitude error. weight_phase : float Weight on phase error (wrapped to [-pi, pi]).

    Returns

    Scalar loss value.

    class NTFFBox(typing.NamedTuple):

    Near-to-far-field transform surface (Huygens box).

    Grid indices defining a closed rectangular box. Must be inside the computational domain and outside any PEC or source regions.

    NTFFBox( i_lo: int, i_hi: int, j_lo: int, j_hi: int, k_lo: int, k_hi: int, freqs: ForwardRef('jnp.ndarray'), cpml_lo_x: int | None = None, cpml_hi_x: int | None = None, cpml_lo_y: int | None = None, cpml_hi_y: int | None = None, cpml_lo_z: int | None = None, cpml_hi_z: int | None = None, face_centre: bool = False, w_x_lo: float = 0.5, w_x_hi: float = 0.5, w_y_lo: float = 0.5, w_y_hi: float = 0.5, w_z_lo: float = 0.5, w_z_hi: float = 0.5)

    Create new instance of NTFFBox(i_lo, i_hi, j_lo, j_hi, k_lo, k_hi, freqs, cpml_lo_x, cpml_hi_x, cpml_lo_y, cpml_hi_y, cpml_lo_z, cpml_hi_z, face_centre, w_x_lo, w_x_hi, w_y_lo, w_y_hi, w_z_lo, w_z_hi)

    i_lo: int

    Alias for field number 0

    i_hi: int

    Alias for field number 1

    j_lo: int

    Alias for field number 2

    j_hi: int

    Alias for field number 3

    k_lo: int

    Alias for field number 4

    k_hi: int

    Alias for field number 5

    freqs: jax.jaxlib._jax.Array

    Alias for field number 6

    cpml_lo_x: int | None

    Alias for field number 7

    cpml_hi_x: int | None

    Alias for field number 8

    cpml_lo_y: int | None

    Alias for field number 9

    cpml_hi_y: int | None

    Alias for field number 10

    cpml_lo_z: int | None

    Alias for field number 11

    cpml_hi_z: int | None

    Alias for field number 12

    face_centre: bool

    Alias for field number 13

    w_x_lo: float

    Alias for field number 14

    w_x_hi: float

    Alias for field number 15

    w_y_lo: float

    Alias for field number 16

    w_y_hi: float

    Alias for field number 17

    w_z_lo: float

    Alias for field number 18

    w_z_hi: float

    Alias for field number 19

    collocation: str

    Readable name of the face-cell layout: face_centre or node.

    @classmethod
    def from_grid( cls, grid, *, i_lo, i_hi, j_lo, j_hi, k_lo, k_hi, freqs, collocation: str = 'face_centre'):

    Build an NTFFBox with the grid's realized per-face padding.

    Physical zero is the inner edge of each lower-face pad, including a zero-cell pad. Symmetric padding keeps the legacy coordinates.

    The box is built for face-centre collocation by default (the second-order layout); pass collocation="node" to reproduce the pre-second-order geometry.

    class NTFFData(typing.NamedTuple):

    Accumulated DFT of tangential fields on 6 faces.

    x faces store [ey, ez, hy, hz], y faces [ex, ez, hx, hz], z faces [ex, ey, hx, hy]. Shape: (n_freqs, face_n1, face_n2, 4).

    Kahan compensation arrays (c_*) maintain near-float64 precision in complex64 accumulation over thousands of timesteps. The value read by the far-field transform is x_lo (the compensated running sum); c_x_lo is the running Kahan residual carried to the next add, not added at read.

    NTFFData( x_lo: ForwardRef('jnp.ndarray'), x_hi: ForwardRef('jnp.ndarray'), y_lo: ForwardRef('jnp.ndarray'), y_hi: ForwardRef('jnp.ndarray'), z_lo: ForwardRef('jnp.ndarray'), z_hi: ForwardRef('jnp.ndarray'), c_x_lo: ForwardRef('jnp.ndarray') = None, c_x_hi: ForwardRef('jnp.ndarray') = None, c_y_lo: ForwardRef('jnp.ndarray') = None, c_y_hi: ForwardRef('jnp.ndarray') = None, c_z_lo: ForwardRef('jnp.ndarray') = None, c_z_hi: ForwardRef('jnp.ndarray') = None)

    Create new instance of NTFFData(x_lo, x_hi, y_lo, y_hi, z_lo, z_hi, c_x_lo, c_x_hi, c_y_lo, c_y_hi, c_z_lo, c_z_hi)

    x_lo: jax.jaxlib._jax.Array

    Alias for field number 0

    x_hi: jax.jaxlib._jax.Array

    Alias for field number 1

    y_lo: jax.jaxlib._jax.Array

    Alias for field number 2

    y_hi: jax.jaxlib._jax.Array

    Alias for field number 3

    z_lo: jax.jaxlib._jax.Array

    Alias for field number 4

    z_hi: jax.jaxlib._jax.Array

    Alias for field number 5

    c_x_lo: jax.jaxlib._jax.Array

    Alias for field number 6

    c_x_hi: jax.jaxlib._jax.Array

    Alias for field number 7

    c_y_lo: jax.jaxlib._jax.Array

    Alias for field number 8

    c_y_hi: jax.jaxlib._jax.Array

    Alias for field number 9

    c_z_lo: jax.jaxlib._jax.Array

    Alias for field number 10

    c_z_hi: jax.jaxlib._jax.Array

    Alias for field number 11

    class FarFieldResult(typing.NamedTuple):

    Far-field radiation result.

    E_theta, E_phi : (n_freqs, n_theta, n_phi) complex Angular far-field components (V·s: the accumulator weights each sample by dt; the 1/r factor is omitted). theta : (n_theta,) radians phi : (n_phi,) radians freqs : (n_freqs,) Hz

    FarFieldResult( E_theta: ForwardRef('np.ndarray'), E_phi: ForwardRef('np.ndarray'), theta: ForwardRef('np.ndarray'), phi: ForwardRef('np.ndarray'), freqs: ForwardRef('np.ndarray'))

    Create new instance of FarFieldResult(E_theta, E_phi, theta, phi, freqs)

    E_theta: numpy.ndarray

    Alias for field number 0

    E_phi: numpy.ndarray

    Alias for field number 1

    theta: numpy.ndarray

    Alias for field number 2

    phi: numpy.ndarray

    Alias for field number 3

    freqs: numpy.ndarray

    Alias for field number 4

    def make_ntff_box( grid: Grid, corner_lo: tuple[float, float, float], corner_hi: tuple[float, float, float], freqs, *, collocation: str = 'face_centre') -> NTFFBox:

    Create an NTFF box from physical coordinates.

    The box is built for face-centre collocation (the second-order layout); pass collocation="node" for the pre-second-order geometry.

    def compute_far_field( ntff_data: NTFFData, box: NTFFBox, grid: Grid, theta: numpy.ndarray, phi: numpy.ndarray) -> FarFieldResult:

    Compute far-field radiation pattern from NTFF DFT data.

    Every face cell contributes one sample of the equivalent surface currents J = n x H and M = -n x E, weighted by the cell's area and phased by its position. Where that sample is taken is what decides the order of the rule: with box.collocation == "face_centre" it sits at the centre of the cell, matching what accumulate_ntff stored there, and the sum is a midpoint rule — second order in the cell size. With "node" (the legacy layout, and the default for a hand-built box) it sits at the cell's lower-corner node, which is a left-endpoint rectangle rule — first order.

    Automatically dispatches to the JAX-differentiable implementation when called inside jax.grad or other JAX tracing contexts. Use this function for both post-processing and optimization objectives.

    Parameters

    ntff_data : NTFFData Accumulated frequency-domain tangential fields. box : NTFFBox grid : Grid theta : (n_theta,) array in radians [0, pi] phi : (n_phi,) array in radians [0, 2*pi]

    Returns

    FarFieldResult

    def compute_far_field_jax( ntff_data, box, grid, theta, phi, *, max_phase_bytes: float = 4000000000.0):

    JAX-differentiable far-field computation for use inside jax.grad.

    Same physics as compute_far_field — including its face-cell sample placement, which is the midpoint rule when the box says collocation="face_centre" and the legacy corner-node rule when it says "node" — but uses jnp throughout, enabling end-to-end differentiation for far-field optimization.

    max_phase_bytes bounds the (n_freqs, n_directions, n_cells) phase array the transform materializes per face; the direction grid is split to stay under it. Splitting theta is EXACT — the sum over surface cells is independent per direction — and the chunked result is bit-identical to the single-shot one. Pass float("inf") to force a single pass.

    def radiation_pattern(ff: FarFieldResult) -> numpy.ndarray:

    Normalized radiation pattern in dB.

    Returns (n_freqs, n_theta, n_phi) array.

    def directivity(ff: FarFieldResult) -> numpy.ndarray:

    Directivity in dBi for each frequency.

    Integrates radiated power over the full sphere using gradient weights and computes D = 4π U_max / P_rad. Requires full-sphere coverage; a single phi cut assumes an axisymmetric pattern.

    Returns (n_freqs,) array.

    def axial_ratio(ff: FarFieldResult) -> numpy.ndarray:

    Axial ratio (AR) of far-field polarization.

    AR = |E_major| / |E_minor| (≥ 1). AR = 1 for circular, ∞ for linear.

    Returns (n_freqs, n_theta, n_phi) array.

    def axial_ratio_dB(ff: FarFieldResult) -> numpy.ndarray:

    Axial ratio in dB. 0 dB = circular, large = linear.

    def polarization_tilt(ff: FarFieldResult) -> numpy.ndarray:

    Polarization tilt angle (orientation of major axis) in radians.

    Returns (n_freqs, n_theta, n_phi) array.

    def polarization_sense(ff: FarFieldResult) -> numpy.ndarray:

    Polarization sense: +1 = RHCP, -1 = LHCP, 0 = linear.

    Returns (n_freqs, n_theta, n_phi) array of integers.

    def antenna_gain( ff: FarFieldResult, *, input_power: numpy.ndarray | float | None = None) -> numpy.ndarray:

    Antenna gain G(theta, phi) for each frequency.

    G = 4*pi * U(theta, phi) / P_ref

    where P_ref = P_rad (IEEE gain) when input_power is None, or P_ref = P_in (realized gain) when input_power is given. P_rad requires full-sphere coverage (a single phi cut assumes axisymmetry). Realized gain accepts partial angular patterns.

    Parameters

    ff : FarFieldResult input_power : array-like (n_freqs,) or scalar, optional Total input power in watts. When provided the result is realized gain which accounts for mismatch loss.

    Returns

    gain : (n_freqs, n_theta, n_phi) array (linear, not dB)

    def antenna_gain_dB( ff: FarFieldResult, *, input_power: numpy.ndarray | float | None = None) -> numpy.ndarray:

    Antenna gain in dBi. Same interface as antenna_gain().

    def antenna_efficiency( ff: FarFieldResult, input_power: numpy.ndarray | float) -> numpy.ndarray:

    Radiation efficiency eta = P_rad / P_in.

    P_rad requires full-sphere coverage (a single phi cut assumes axisymmetry), even though input_power is supplied.

    Parameters

    ff : FarFieldResult input_power : array-like (n_freqs,) or scalar Total input power in watts.

    Returns

    eta : (n_freqs,) array, values in [0, inf) theoretically in (0, 1].

    def half_power_beamwidth( ff: FarFieldResult, *, plane: str = 'E', freq_idx: int = 0) -> float:

    Half-power (3 dB) beamwidth in degrees.

    Parameters

    ff : FarFieldResult plane : "E" (phi=0 cut) or "H" (phi=pi/2 cut). freq_idx : frequency index.

    Returns

    hpbw : float, degrees. Returns nan if the 3 dB points cannot be found (e.g. omnidirectional in the cut plane).

    def front_to_back_ratio(ff: FarFieldResult, *, freq_idx: int = 0) -> float:

    Front-to-back ratio in dB.

    F/B = max gain in forward hemisphere (theta < 90 deg) - max gain in backward hemisphere (theta > 90 deg)

    Parameters

    ff : FarFieldResult freq_idx : frequency index.

    Returns

    fb_dB : float

    def antenna_bandwidth( s11: numpy.ndarray, freqs: numpy.ndarray, *, threshold_db: float = -10.0) -> BandwidthResult:

    Impedance bandwidth where |S11| < threshold_db.

    Finds the widest contiguous frequency range below the threshold.

    Parameters

    s11 : (n_freqs,) complex or real. If complex, magnitude is computed. If real, assumed to be in dB. freqs : (n_freqs,) Hz. threshold_db : float Threshold in dB (default -10 dB).

    Returns

    BandwidthResult

    class BandwidthResult(typing.NamedTuple):

    Impedance bandwidth extraction result.

    center_freq : float, Hz bandwidth_hz : float, Hz fractional_bandwidth : float (ratio, e.g. 0.10 = 10 %) freq_lo : float, Hz — lower edge freq_hi : float, Hz — upper edge

    BandwidthResult( center_freq: float, bandwidth_hz: float, fractional_bandwidth: float, freq_lo: float, freq_hi: float)

    Create new instance of BandwidthResult(center_freq, bandwidth_hz, fractional_bandwidth, freq_lo, freq_hi)

    center_freq: float

    Alias for field number 0

    bandwidth_hz: float

    Alias for field number 1

    fractional_bandwidth: float

    Alias for field number 2

    freq_lo: float

    Alias for field number 3

    freq_hi: float

    Alias for field number 4

    def plot_antenna_summary( ff: FarFieldResult, *, s11: numpy.ndarray | None = None, freqs: numpy.ndarray | None = None, freq_idx: int = 0, input_power: numpy.ndarray | float | None = None) -> object:

    Multi-panel antenna summary figure.

    Panels:

    1. Radiation pattern (polar, E-plane and H-plane)
    2. Gain vs theta (rectangular)
    3. S11 vs frequency (if provided)
    4. Efficiency vs frequency or gain statistics

    Parameters

    ff : FarFieldResult s11 : (n_freqs,) complex or real, optional freqs : (n_freqs,) Hz for the S11 data, optional freq_idx : frequency index for pattern plots. input_power : for realized gain computation, optional.

    Returns

    matplotlib Figure

    @dataclass(frozen=True)
    class DesignRegion:

    Rectangular region where material parameters are optimizable.

    Parameters

    corner_lo, corner_hi : (x, y, z) in metres Bounding box of the design region. eps_range : (eps_min, eps_max) Permittivity bounds. The optimizer works in a continuous latent space and projects to this range via sigmoid.

    DesignRegion( corner_lo: tuple[float, float, float], corner_hi: tuple[float, float, float], eps_range: tuple[float, float] = (1.0, 12.0))
    corner_lo: tuple[float, float, float]
    corner_hi: tuple[float, float, float]
    eps_range: tuple[float, float] = (1.0, 12.0)
    @dataclass
    class OptimizeResult:

    Result of an optimization run.

    Attributes

    eps_design : (nx_d, ny_d, nz_d) float array Optimized permittivity in the design region. loss_history : list of float Objective value at each iteration. latent : jnp.ndarray Final latent parameters (pre-sigmoid).

    OptimizeResult( eps_design: jax.jaxlib._jax.Array, loss_history: list[float], latent: jax.jaxlib._jax.Array)
    eps_design: jax.jaxlib._jax.Array
    loss_history: list[float]
    latent: jax.jaxlib._jax.Array
    def optimize( sim, region: DesignRegion, objective: Callable, *, n_iters: int = 50, lr: float = 0.01, init_latent: jax.jaxlib._jax.Array | None = None, n_steps: int | None = None, num_periods: float = 20.0, verbose: bool = True, skip_preflight: bool = False, checkpoint_every: int | None = None, emit_time_series: bool = True, n_warmup: int = 0, distributed: bool = False, port_s11_freqs: object | None = None, checkpoint_segments: int | None = None, n_starts: int = 1, best_iterate: bool = False, step_clamp: float | None = None, seed: int = 0, jit: bool = False) -> OptimizeResult:

    Run gradient-based optimization on a design region.

    Refuses simulations with refinement, as the underlying forward() path has no subgridded solve.

    Parameters

    sim : Simulation Base simulation (the design region overrides eps_r in its box). region : DesignRegion Design region specification. objective : callable(Result) -> scalar Objective function to minimize. Must be JAX-differentiable through the simulation. n_iters : int Number of optimization iterations. lr : float Learning rate (Adam). init_latent : array or None Initial latent parameters. If None, initialized to zeros (maps to midpoint of eps_range). n_steps : int or None Number of simulation timesteps per iteration. Controls memory usage — fewer steps = less memory for jax.grad. If None, auto-computed from num_periods. num_periods : float Periods at freq_max for auto n_steps (default 20). Reduce to 10 for lower memory usage with minimal accuracy loss. verbose : bool Print progress every 10 iterations. n_warmup : int Forwarded verbatim to sim.forward(n_warmup=...) at every iteration (issue #40). On a non-uniform / distributed-non-uniform sim, this trades an APPROXIMATE (truncated) gradient for reduced reverse-mode memory/compute — see Simulation.forward's own n_warmup docstring for the measured error curve (issue #626 part 2); a design loop built on this objective inherits that bias, not just a single forward call, so verify the bias is acceptable for your loss window before using a nonzero value here. On a uniform-mesh sim it raises NotImplementedError (issue #626 — previously a silent no-op). Default 0 (no warmup split, full gradient). port_s11_freqs : array-like or None Frequencies (Hz) at which to accumulate per-port V/I DFTs in the JIT scan body so that result.s_params is populated with wave-decomposition |S11| values. Required when the objective is minimize_s11_at_freq_wave_decomp() (issue #72). checkpoint_segments : int or None If set, route sim.forward through the segmented-checkpoint path (issue #73) which trades ≈2× compute for ≈√n_steps memory. Must divide n_steps; useful when n_steps is large enough that the linear-memory scan would OOM. None (default) preserves the legacy per-step jax.checkpoint behaviour.

    Currently wired only on the uniform single-device forward path.
    Non-uniform meshes and ``distributed=True`` will raise
    ``NotImplementedError``; NU support is tracked as a follow-up
    on issue #73.
    

    n_starts : int Number of random-initialized restarts (opt-in; default 1 = the legacy single run). Start 0 always uses init_latent (so n_starts=1 is byte-identical to before this parameter existed); starts 1..N-1 draw i.i.d. standard-normal latents from seed. The best restart by final loss (or best-visited loss when best_iterate=True) is returned. Promoted from the MSL open-stub multimodal example (issue #171); the surface must be multimodal for extra starts to help. best_iterate : bool If True, return the lowest-loss iterate visited during the run rather than the final iterate (default False = final iterate = legacy behaviour). Guards the sharp-null overshoot the #171 GPU run caught. Selection among n_starts restarts uses the same best-visited loss when this is True. step_clamp : float or None If set (>0), clamp the L2 norm of each Adam latent update to this value, rescaling the whole step (direction preserved) when it overshoots. Default None = unclamped = legacy behaviour. Generic analogue of the example's physical-length clamp. seed : int PRNG seed for the extra n_starts restart inits (deterministic / reproducible). Unused when n_starts=1. jit : bool Compile the loss and its gradient once per optimize() call and reuse the program in every iteration and every start (#1225). Default False runs jax.value_and_grad eagerly, which compiles the whole FDTD solve again on every iteration — on a board-sized model most of the iteration's wall time.

    With ``jit=True`` the objective is traced, not run, so it must not
    read a traced value on the host (``float(...)``, ``np.asarray(...)``,
    a Python ``if`` on an array). Wire ports on either mesh and lumped
    ports on a graded mesh trace since #1225. Known exception: a model
    with an MSL port —
    its port set-up reads the realized PEC mask on the host
    (``rfx/sources/msl_port.py``) and the MSL wave-decomposition
    extractor converts a grid spacing with ``float()``
    (``rfx/sparams/_common.py:143``); under ``jit=True`` the call stops
    with ``TracerArrayConversionError`` or ``ConcretizationTypeError``.
    Use the default there.
    
    The jitted loss and gradient can differ from the eager ones in the
    last float32 bits, because XLA compiles the whole step as one
    program. Measured on CPU in float32 ULP at the peak of each array
    (``max|eager - jitted|`` over the spacing at ``max|eager|``): 0 to 9
    in the loss and up to ~20 in a design-region gradient on the models
    tested, and ~150 in the loss of a waveguide-port model. These are
    measurements, not a bound: on a wire-port model where two parts of
    the objective's gradient partly cancel at the port cell, the
    whole-grid permittivity gradient differed by 12 to 56 ULP at the
    peak. The optimisation path can therefore drift from an eager run's
    at that level.
    

    Returns

    OptimizeResult For n_starts>1 this is the winning restart's result (loss_history is that restart's own trajectory).

    @dataclass
    class GradientCheckResult:

    Result of AD-vs-FD gradient comparison.

    Attributes

    ad_grad : jnp.ndarray Gradient from reverse-mode autodiff. fd_grad : jnp.ndarray Gradient from central finite differences. relative_error : float max |ad - fd| / (max |ad| + max |fd| + 1e-30).

    GradientCheckResult( ad_grad: jax.jaxlib._jax.Array, fd_grad: jax.jaxlib._jax.Array, relative_error: float)
    ad_grad: jax.jaxlib._jax.Array
    fd_grad: jax.jaxlib._jax.Array
    relative_error: float
    def gradient_check( sim, design_params: jax.jaxlib._jax.Array, objective_fn: Callable, *, eps: float = 0.001, n_steps: int | None = None, num_periods: float = 20.0) -> GradientCheckResult:

    Compare AD gradient with finite-difference gradient.

    Runs the simulation via sim.forward() with permittivity overrides derived from design_params, computes the gradient of objective_fn with respect to design_params using both reverse-mode AD and central finite differences, and returns the comparison.

    Parameters

    sim : Simulation Configured simulation (ports, probes already added). design_params : jnp.ndarray Flat array of design parameters. These are added element-wise to the baseline eps_r from sim._assemble_materials(). Shape must broadcast with grid.shape. objective_fn : callable(SimResult) -> scalar Differentiable objective function. eps : float Finite-difference perturbation (default 1e-3). n_steps : int or None Number of timesteps. Passed to sim.forward(). num_periods : float Periods at freq_max if n_steps is None.

    Returns

    GradientCheckResult Contains ad_grad, fd_grad, and relative_error.

    @dataclass(frozen=True)
    class ProgressiveStage:

    One resolution stage in a progressive_optimize schedule.

    Attributes

    dx : float Target cell size for this stage (metres). Passed to sim_factory. n_iters : int Number of Adam iterations at this resolution. lr : float Learning rate for this stage (defaults to 0.01). num_periods : float Periods at freq_max for auto n_steps computation (default 20). n_steps : int or None Explicit step count override. None -> auto from num_periods.

    ProgressiveStage( dx: float, n_iters: int, lr: float = 0.01, num_periods: float = 20.0, n_steps: int | None = None)
    dx: float
    n_iters: int
    lr: float = 0.01
    num_periods: float = 20.0
    n_steps: int | None = None
    @dataclass
    class ProgressiveOptimizeResult:

    Result of a progressive_optimize run.

    Attributes

    stages : list of OptimizeResult Per-stage optimize() output, in schedule order. final_eps_design : jnp.ndarray Optimized permittivity at the finest stage's design-region shape. final_latent : jnp.ndarray Final latent at the finest stage's shape. loss_history : list of float Concatenated loss across all stages. stage_boundaries : list of int Iteration index where each stage starts (cumulative sum of n_iters). Useful for plotting.

    ProgressiveOptimizeResult( stages: list[OptimizeResult], final_eps_design: jax.jaxlib._jax.Array, final_latent: jax.jaxlib._jax.Array, loss_history: list[float], stage_boundaries: list[int])
    stages: list[OptimizeResult]
    final_eps_design: jax.jaxlib._jax.Array
    final_latent: jax.jaxlib._jax.Array
    loss_history: list[float]
    stage_boundaries: list[int]
    def progressive_optimize( sim_factory: Callable[[float], object], region: DesignRegion, objective: Callable, schedule: list[ProgressiveStage], *, init_latent: jax.jaxlib._jax.Array | None = None, interp_method: str = 'linear', verbose: bool = True, skip_preflight: bool = False, checkpoint_every: int | None = None, emit_time_series: bool = True, n_warmup: int = 0, distributed: bool = False) -> ProgressiveOptimizeResult:

    Progressive multi-resolution inverse design (issue #42).

    Runs optimize() at each stage in schedule, upsampling the latent between stages. Coarse stages produce a fast loss-landscape scan; finer stages refine on top.

    Parameters

    sim_factory : callable(dx) -> Simulation Builds a Simulation at the given dx. Called once per stage. region : DesignRegion Design region in physical coordinates (resolution-independent). objective : callable(Result) -> scalar JAX-differentiable loss. schedule : list of ProgressiveStage Must be non-empty. Stages usually go coarse -> fine. init_latent : jnp.ndarray or None Initial latent at the FIRST stage's design-region shape. If None, optimize() initialises to zeros. interp_method : str Latent-upsample interpolation ("linear", "nearest", "cubic"). verbose : bool Print per-stage progress banner. n_warmup : int Forwarded to each stage's optimize(..., n_warmup=...) call — see optimize()'s own n_warmup entry for the full behaviour (issue #40/#626): an approximation on non-uniform meshes, a fail-loud NotImplementedError on uniform ones.

    Returns

    ProgressiveOptimizeResult

    Notes

    The design variable basis is the per-stage latent (not a stage- independent per-wavelength grid). jax.image.resize bridges stages, which is adequate when the mesh refinement is modest. For dramatic resolution jumps, consider a dedicated per-wavelength basis.

    @dataclass(frozen=True)
    class TopologyDesignRegion:

    Density-based design region for topology optimization.

    Each cell in the region has a continuous density rho in [0, 1]. rho=0 corresponds to material_bg (background) and rho=1 to material_fg (foreground).

    Parameters

    corner_lo, corner_hi : (x, y, z) in metres Bounding box of the design region. material_bg : str Background material name (from MATERIAL_LIBRARY). material_fg : str Foreground material name. filter_radius : float or None Radius (in metres) for the cone density filter. If None, no filtering is applied. beta_projection : float Initial sharpness of the threshold projection. beta=1 is gentle; beta>=64 is near-binary. min_feature_size : float or None Alias for filter_radius (diameter = 2 * filter_radius). If provided, overrides filter_radius.

    TopologyDesignRegion( corner_lo: tuple[float, float, float], corner_hi: tuple[float, float, float], material_bg: str = 'air', material_fg: str = 'pec', filter_radius: float | None = None, beta_projection: float = 1.0, min_feature_size: float | None = None)
    corner_lo: tuple[float, float, float]
    corner_hi: tuple[float, float, float]
    material_bg: str = 'air'
    material_fg: str = 'pec'
    filter_radius: float | None = None
    beta_projection: float = 1.0
    min_feature_size: float | None = None
    effective_filter_radius: float | None

    Return the filter radius, accounting for min_feature_size alias.

    @dataclass
    class TopologyResult:

    Result of a topology optimization run.

    Attributes

    density : jnp.ndarray Final (raw) density field in the design region. density_projected : jnp.ndarray Final density after filtering and projection. eps_design : jnp.ndarray Final permittivity distribution in the design region. history : list of float Objective value at each iteration. beta_history : list of float Projection sharpness at each iteration. final_result : object Simulation result from the last iteration.

    TopologyResult( density: jax.jaxlib._jax.Array, density_projected: jax.jaxlib._jax.Array, eps_design: jax.jaxlib._jax.Array, history: list[float], beta_history: list[float], final_result: object = None, pec_occupancy_design: jax.jaxlib._jax.Array | None = None)
    density: jax.jaxlib._jax.Array
    density_projected: jax.jaxlib._jax.Array
    eps_design: jax.jaxlib._jax.Array
    history: list[float]
    beta_history: list[float]
    final_result: object = None
    pec_occupancy_design: jax.jaxlib._jax.Array | None = None
    loss_history: list[float]

    Alias for history, consistent with OptimizeResult.loss_history.

    def topology_optimize( sim, design_region: TopologyDesignRegion, objective: Callable, *, n_iterations: int = 100, learning_rate: float = 0.01, beta_schedule: list[tuple[int, float]] | None = None, init_density: jax.jaxlib._jax.Array | None = None, verbose: bool = True, skip_preflight: bool = False) -> TopologyResult:

    Run density-based topology optimization.

    Optimizes a continuous density field within the design region to minimize an objective function, using gradient information from jax.value_and_grad through the full FDTD pipeline.

    Uses optax.adam for the optimizer.

    Parameters

    sim : Simulation Base simulation (the design region overrides eps_r in its box). design_region : TopologyDesignRegion Region and material specification. objective : callable(Result) -> scalar Objective function to minimize. Must be JAX-differentiable through the simulation. n_iterations : int Number of optimization iterations. learning_rate : float Learning rate for Adam optimizer. beta_schedule : list of (iteration, beta) or None Schedule for increasing projection sharpness. Default: [(0, 1), (30, 4), (60, 16), (80, 64)] init_density : array or None Initial density field. If None, initialized to 0.5 everywhere (uniform mixture of bg and fg materials). verbose : bool Print progress every 10 iterations.

    Returns

    TopologyResult Contains final density, permittivity, loss history, and beta history.

    def density_to_eps( rho: jax.jaxlib._jax.Array, eps_bg: float, eps_fg: float, filter_radius_cells: float | None = None, beta: float = 1.0, sigma_bg: float = 0.0, sigma_fg: float = 0.0) -> tuple[jax.jaxlib._jax.Array, jax.jaxlib._jax.Array]:

    Backward-compatible density -> (eps, sigma) helper.

    def minimize_s11( freqs: jax.jaxlib._jax.Array | numpy.ndarray, target_db: float = -10.0) -> Callable:

    Minimize |S11|² over specified frequencies.

    Returns a callable objective(result) -> scalar that computes the mean |S11|² across the nearest frequency bins. If the mean S11 is already below target_db the loss is clamped to zero.

    Parameters

    freqs : array-like, shape (n,) Target frequencies (Hz) over which to minimize S11. target_db : float Threshold in dB. When mean |S11|² is below this, loss = 0.

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def maximize_s21(freqs: jax.jaxlib._jax.Array | numpy.ndarray) -> Callable:

    Maximize |S21|² (transmission) over specified frequencies.

    Returns -mean|S21|² so that minimizing this objective maximizes transmission.

    Parameters

    freqs : array-like, shape (n,) Target frequencies (Hz).

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def target_impedance(freq: float, z_target: float = 50.0) -> Callable:

    Minimize |Z_in − z_target|² at a specific frequency.

    Computes input impedance from S11: Z_in = Z0 * (1 + S11) / (1 − S11) where Z0 = 50 Ω (standard reference impedance).

    Parameters

    freq : float Target frequency (Hz). z_target : float Desired input impedance (Ω).

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def maximize_bandwidth(f_center: float, f_bw: float, s11_threshold: float = -10.0) -> Callable:

    Maximize the bandwidth where |S11| < threshold.

    Evaluates S11 over a frequency band [f_center − f_bw/2, f_center + f_bw/2] and returns a soft loss that is lower when more frequency bins satisfy |S11|_dB < s11_threshold.

    The loss is computed as the mean of max(|S11|²_dB − threshold, 0) across the band, so it drives the optimizer to push all bins below threshold simultaneously, effectively maximizing matched bandwidth.

    Parameters

    f_center : float Center frequency (Hz). f_bw : float Bandwidth span (Hz). s11_threshold : float Threshold in dB (default −10 dB).

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def maximize_directivity( theta_target: float, phi_target: float, *, n_theta: int = 37, n_phi: int = 73, log_ratio: bool = True, eps: float = 1e-37) -> Callable:

    Maximize directivity in a target direction (ratio-based, scale-invariant).

    Computes the directivity ratio U(θ_target, φ_target) / P_rad where U = |E_θ|² + |E_φ|² is the radiation intensity and P_rad is the total radiated power integrated over the FULL sphere (the same coverage as farfield.directivity() and antenna._total_radiated_power, retaining this objective's JAX trapezoid endpoint weights — so the optimized quantity is the true directivity, not an upper-hemisphere proxy that would inflate D for antennas with any back radiation). The ratio is scale-invariant, so the absolute magnitude of the NTFF spectral integral drops out — gradients reflect pattern shape only.

    Prior versions used absolute |E|² at the target direction, which is ~1e-27 in rfx's spectral NTFF convention and produces zero gradients in topology_optimize (GitHub issue #32).

    Parameters

    theta_target : float Polar angle in radians [0, π]. phi_target : float Azimuthal angle in radians [0, 2π]. n_theta : int, optional Number of polar samples in [0, π] for the full-sphere P_rad integration (default 37 → 5° spacing). n_phi : int, optional Number of azimuthal samples in [0, 2π] (default 73 → 5° spacing).

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    Parameters (continued)

    log_ratio : bool, optional Default True: optimize -(log U - log P) — the full, sign-correct quotient gradient U'/U - P'/P that is right for every degree of freedom (including power-changing ones). log_ratio=False is refused because it gives wrong-sign gradients for power-changing variables. eps : float, optional Floor for the log_ratio log arguments (default 1e-37). It only guards log(0); the log-ratio gradient U'/U is scale-invariant, so eps must be BELOW the working U/P magnitude (rfx spectral NTFF U,P are ~1e-27 in full antenna runs, ~1e-32 in small/early sims). A floor at or above U/P (e.g. the old 1e-30 on a 1e-32 sim) clamps the log argument and zeros the gradient — keep eps well below min(U, P).

    Notes

    The removed log_ratio=False mode omitted the denominator derivative, giving wrong-sign gradients for power-changing variables. Use the default.

    def minimize_reflected_energy(port_probe_idx: int = 0, *, late_fraction: float = 0.5) -> Callable:

    Time-domain S11 proxy: minimize late-time reflected energy at port.

    Computes the ratio of energy in the second half of the probe time series (dominated by reflections) to energy in the first half (dominated by the incident pulse). Minimizing this ratio drives the optimizer toward better impedance matching.

    This objective works with optimize() and topology_optimize() because it uses only result.time_series (no S-parameters needed).

    The "late half = reflection" split is only valid when the round trip from the port to the reflecting feature and back arrives after the split (1 - late_fraction of the series) and before the run ends. On a short round-trip geometry (thin substrate, feature near the port) the reflection lands in the early window and the late window is nearly empty, so this ratio collapses toward 0 and its gradient is numerical noise -- with no error raised. Sanity-check the returned magnitude (a meaningfully reflecting design sits near 1e-2--1e-1, not ~1e-7), then fix an empty window by its mode: enlarge n_steps if the reflection arrives after the run ends; RAISE late_fraction (which moves the split earlier) if it arrives before the split; if the incident pulse and reflection overlap in time on a very short round trip, no split separates them -- narrow the source bandwidth or use an impedance-referenced port. An AD-vs-finite-difference check does NOT catch this: both differentiate the same empty window and agree.

    Parameters

    port_probe_idx : int Index into result.time_series columns identifying the probe co-located with the excitation port (default 0). late_fraction : float Fraction of the time series considered "late" (default 0.5). A value of 0.5 means the second half is treated as reflection.

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def maximize_transmitted_energy(output_probe_idx: int = -1) -> Callable:

    Time-domain S21 proxy: maximize energy at an output probe.

    Returns the negated total squared energy at the output probe, so that minimizing this objective maximizes transmission.

    This objective works with optimize() and topology_optimize() because it uses only result.time_series (no S-parameters needed).

    Parameters

    output_probe_idx : int Index into result.time_series columns for the output probe. Default -1 (last probe).

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def steer_probe_array( target_probe_idx: int, suppress_probe_idx: int = 0, *, late_fraction: float = 0.5) -> Callable:

    Steer radiation toward target_probe_idx and away from suppress_probe_idx.

    Uses a near-field probe array as a differentiable surrogate for far-field beam steering. Place probes at different spatial positions around the antenna (e.g., above-left and above-right), then this objective maximizes the power ratio between target and suppressed probe.

    This avoids NTFF DFT entirely and works reliably in float32.

    Setup example::

    # Probes at different angles in the near field
    sim.add_probe((x_center - 0.02, y_center, z_above), "ez")  # probe 0: left
    sim.add_probe((x_center + 0.02, y_center, z_above), "ez")  # probe 1: right
    
    # Steer toward probe 1 (right), suppress probe 0 (left)
    obj = steer_probe_array(target_probe_idx=1, suppress_probe_idx=0)
    

    Parameters

    target_probe_idx : int Column index in result.time_series to maximize. suppress_probe_idx : int Column index to suppress (default 0). late_fraction : float Fraction of time series to use (late portion, after source decays).

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def dft_field(names: Union[str, Sequence[str]], *, stack: bool = True) -> Callable:

    Result accessor for one or more registered DFT-plane probes.

    Parameters

    names : str or sequence of str The name= (or auto-generated default) given to add_dft_plane_probe(...). A single name returns the raw (n_freqs, n1, n2) complex accumulator directly (no dict wrapping -- there is nothing to disambiguate). A sequence of names returns either a stacked array or a dict, controlled by stack. stack : bool When names is a sequence and stack=True (default), the accessor stacks all requested planes into one (n_names, n_freqs, n1, n2) array ALONG A NEW LEADING AXIS -- but only when every requested plane has the same (n_freqs, n1, n2) shape (planes at different axes/coordinates commonly do not, e.g. a yz-plane vs an xz-plane monitor). When the shapes differ, stacking is impossible and the accessor RAISES ValueError naming the mismatched shapes and pointing at stack=False; it never silently truncates or broadcasts. Pass stack=False explicitly to opt into the per-name dict[name] -> array form instead (also the unconditional return form when names is a sequence and stack=False, even if the shapes happen to match). Mixed-dtype stacking (e.g. a complex64 plane alongside a complex128 one, possible if planes were registered under different jax_enable_x64 states) follows ordinary jnp.stack dtype promotion -- the result takes the wider dtype.

    Returns

    callable(result) -> jnp.ndarray | dict[str, jnp.ndarray] JAX-differentiable: the returned array(s) are views/stacks of the same accumulator that was carried through the FDTD scan, so jax.grad flows through them unchanged.

    Raises

    ValueError If result carries no dft_planes at all, is missing one of the requested names, or (list form, stack=True) the requested planes' shapes do not match -- naming the fix in each case.

    def field_energy( names: Union[str, Sequence[str]], *, weights: dict[str, float] | None = None) -> Callable:

    Objective factory: sum(|field|**2) over one or more DFT planes.

    Sums squared magnitude over every element (space AND frequency) of every requested plane. Trivially smooth (a sum of squares is C-infinity in the accumulator), so this is a good first objective to sanity-check a new DFT-plane setup with before reaching for field_softmax().

    Parameters

    names : str or sequence of str Plane name(s) to sum over -- see dft_field(). Shapes need not match between planes (unlike dft_field(..., stack=True)): each plane's energy is summed independently, so mixing e.g. a yz-plane and an xz-plane monitor is fine. weights : dict[str, float] or None Optional per-plane scalar weight (default 1.0 for every plane not listed), applied before summing -- e.g. to down-weight a monitor plane closer to the source.

    Returns

    callable(Result) -> scalar (JAX-differentiable)

    def field_softmax(names: Union[str, Sequence[str]], *, beta: float = 1.0) -> Callable:

    Objective factory: smooth (soft) max of |field|**2 over space+frequency.

    Uses the standard log-sum-exp soft-max, AUTO-SCALED to the field's own magnitude each call: beta_eff = beta / stop_gradient(max(vals)), softmax = logsumexp(beta_eff * vals) / beta_eff, computed with jax.nn.logsumexp for numerical stability. As beta (equivalently beta_eff * max(vals), which the normalization pins to exactly beta by construction -- see "Auto-scaling", below) grows this converges to the true max(|field|**2); a smaller beta trades a looser max approximation for a smoother, better-conditioned gradient (useful early in an optimization run, e.g. the shielding case: minimize the worst-case leaked field anywhere behind a barrier, over all monitored frequencies, rather than the mean leaked field).

    Parameters

    names : str or sequence of str Plane name(s) to pool over -- see dft_field(). All requested planes' elements (space AND frequency, across every plane) are pooled into ONE soft-max, so this composes the register-N-planes pattern: register one plane per physical monitor location, then soft-max over all of them at once for a single worst-case scalar. beta : float Soft-max sharpness (default 1.0), now DIMENSIONLESS -- see "Auto-scaling" below. Must be positive.

    Auto-scaling (fixes a measured footgun -- read before raising beta

    "to be safe")

    Earlier versions of this function computed logsumexp(beta*vals)/beta directly against the RAW |field|**2 values, so beta had to be hand-matched to whatever physical units/magnitude the accumulator happened to carry (rfx's own DFT-plane accumulators commonly sit around |field|**2 ~1e-10 to 1e-22 depending on geometry/normalization). At the then-default beta=1.0 the objective sat at ~100.000002% of the design-independent constant log(count)/beta (measured, #619): both the value and its gradient were differentiating ROUNDING NOISE in that constant, not physics, and it could coincidentally still pass a single finite-difference check while failing at a different step size (a feedback_gate_can_bind_artifact-class trap).

    This function now computes beta_eff = beta / stop_gradient(max(vals)) and uses beta_eff in place of a raw beta everywhere above. Because vals' own current max scales out of the product exactly, beta_eff * max(vals) == beta ALWAYS (BEFORE any overflow -- see the next paragraph), regardless of the field's physical units -- the log(count)/beta swallow above cannot recur at any REPRESENTABLE (nonzero) field magnitude, at any beta, including the default. This is scoped deliberately: below the working dtype's smallest representable magnitude (~1.4e-45 for float32 subnormals) vals itself underflows to a literal 0 array, the degenerate all-zero fallback below kicks in (beta_eff = beta, i.e. UNSCALED), and the original swallow pattern can in principle recur there -- rfx's own realistic DFT-plane field magnitudes (|field|**2 ~1e-10 to 1e-22) sit ~23-35 orders of magnitude above that floor, so this is a dtype-representability edge, not a practical concern at any field magnitude this simulator actually produces. stop_gradient on the normalizer is deliberate: without it, the returned scalar would differentiate the (physically meaningless) rescaling itself rather than approximating max; with it, the gradient is the standard softmax-weighted average at the CURRENT call's beta_eff (a real, directionally-correct signal, recomputed fresh -- and re-normalized -- every call).

    What beta now controls (and does not control): a larger beta tightens the approximation -- the softmax value can exceed the true max(vals) by a factor of at most 1 + log(count)/beta (default beta=1.0 is therefore LOOSE for a field with many samples: e.g. ~14.8x at count=1e6), so raise beta (5-50 is a reasonable range for most plane sizes) for a tighter approximation once you know you need one, exactly as before -- and the DEFAULT is now safe at any field magnitude, merely loose, never rounding-noise.

    The top-end overflow footgun MOVED, it did not disappear -- and it now has TWO thresholds, not one. Auto-scaling divides by max(vals) instead of multiplying by the raw field magnitude, so a large enough beta against a physically tiny field can still overflow -- now in computing beta_eff = beta / max(vals) itself, before vals is even touched. Measured at max(vals) ~2.24e-22 (this repo's realistic DFT-plane range):

    1. Value overflow (beta_eff itself overflows). beta at or above ~1e17 makes beta_eff exceed float32's range, previously returning value=nan, grad=nan silently, no exception. This function guards it: when beta_eff would be non-finite, it is clipped to the largest finite value the working dtype represents (jnp.finfo(vals.dtype).max) rather than propagating nan. The clipped VALUE is accurate (still converges to the true max(vals), verified at beta up to 1e38).
    2. Gradient collapse, measured to start EARLIER (~1e15-1e17) and NOT covered by the value clip above. Even where the value stays finite and correct, the returned gradient silently drops to EXACTLY 0.0 somewhere in this range -- the precise threshold is FIXTURE-DEPENDENT (measured to shift by up to 10x between two fixtures differing only in element values, so no single number is trustworthy across geometries) -- a sharp cliff, not a gradual falloff, correct to machine precision on one side and exactly 0.0 on the other. A reciprocal-of-a-huge-number precision limit in the VJP's 1/beta_eff term, distinct from (and tighter than) the value-overflow threshold above. This is NOT fixed by the value clip (it happens at a lower beta than clipping even engages) and is not attempted here: it would need a different numerical formulation (e.g. normalizing vals by scale before scaling by beta, which trades this gradient cliff for a symmetric one on the VALUE side at extreme beta / tiny-field combinations -- no formulation tried avoids both).

    Practical guidance: both thresholds sit many orders of magnitude above the 5-50 dimensionless range recommended above, and above the 100-1e6 range this repo's own re-calibrated tests and examples actually use -- treat beta past ~1e6 as unverified, not merely "less sharp than useful."

    def jacobian_fwd( sim_fn: Callable, params: Any, *, tangents: Any = 'identity', batch_tangents: bool = True) -> tuple[typing.Any, typing.Any]:

    Batched forward-mode Jacobian of sim_fn at params (issue #577).

    jax.jvp / jax.jacfwd / jax.vmap(jax.jvp) already run end-to-end through sim.forward(eps_override=...) -> dft_field() / field_energy() / field_softmax() today, with zero solver changes -- this function is a thin, gated PACKAGING layer over that stock-JAX capability (tangent-basis construction, dtype/shape validation, primal de-duplication, sequential-vs-batched dispatch), not a new AD engine. It computes

    ``value, jacobian = jacobian_fwd(sim_fn, params)``
    

    where value = sim_fn(params) (bit-identical to calling sim_fn directly -- see the API trap note below) and jacobian has the SAME pytree structure as value (sim_fn's OUTPUT, not params -- this is what jax.jvp's own tangent output mirrors, and this function does not reshape it into a params-keyed structure the way jax.jacfwd does), with every leaf gaining one new LEADING axis of size n_t: jacobian (or jacobian[k] for an output pytree with leaf k) has shape (n_t,) + value.shape, row i holding the directional derivative of value along tangent direction i. With the default tangents="identity", direction i corresponds to params' i-th flattened leaf (jax.tree_util.tree_flatten order) -- i.e. jacobian[i] is d(value)/d(params_leaf[i]).

    Mechanism: jax.vmap over the TANGENT argument of jax.jvp (NOT jax.linearize). Measured on the real uniform-lane FDTD scan: the vmap(jvp) jaxpr contains exactly ONE lax.scan (the FDTD time loop) whose carry holds one UNBATCHED copy of the field state (the primal sweep) plus n_t BATCHED tangent copies -- the primal sweep really is shared across every tangent direction, which is the premise

    577 asked to verify. jax.linearize was measured to cost 24x more

    peak memory on the same fixture (8654 MB vs 355 MB at n_steps=600, n_t=10) because it materialises the whole scan's primal residuals -- i.e. the reverse-mode tape -- which forward mode never needs.

    API TRAP (verify this, do not assume it): a naive jax.vmap(jax.jvp(...)) ALSO stacks the primal, returning a (n_t,) + value.shape array of n_t copies of the same value. This function passes out_axes=(None, 0) to jax.vmap so the returned value is the single, unstacked primal -- but that only works because the primal computation genuinely does not depend on which tangent direction is being evaluated. If you change sim_fn in a way that breaks that invariant, jax.vmap raises loudly (out_axes=None on an output that DOES depend on the mapped axis is a vmap error, not a silent wrong answer). batch_tangents=False (the sequential path) has NO jax.vmap of its own to inherit that check from -- it just calls jax.jvp n_t times in a plain Python loop and returns values[0], so without an explicit guard a sim_fn that violates the invariant would silently return one arbitrary tangent row's primal on that path instead of raising. This function closes that gap on the sequential path via two guards, so the memory-vs-speed knob is not also a safety-vs-speed knob: (1) a jax.eval_shape abstract trace of the SAME out_axes=(None, 0) vmap the batched path actually runs -- shape-only, zero FLOPs, zero extra memory (verified: reproduces the identical ValueError a real violating vmap call raises, including when this whole call is itself wrapped in an outer jax.jit by the caller, since eval_shape is trace-time like vmap's own check); (2) when running eagerly (params are not themselves tracers, so the computed primal values are concrete), an exact pairwise comparison of all n_t sequentially-computed primal values, raising ValueError on any mismatch -- a strictly stronger, numeric confirmation of the same invariant, skipped only when it is not expressible (under an enclosing jax.jit, where guard (1) is the check in effect).

    Cost characterization: NOT "a few times one solve" -- see scripts/benchmark_jacobian_fwd.py (regenerable; run it for current numbers on your hardware/grid) and the CHANGELOG entry for this issue for measured wall-time/flops/memory ratios. The honest summary: wall time and flops scale roughly as (1 + n_t) plain solves and that ratio MOVES with grid size (do not trust one ratio across problem sizes); memory is the argument FOR this mode, not speed -- forward-mode peak is O(state) * (1 + n_t) and independent of n_steps, while reverse mode without segmented checkpointing is tens of times one plain solve even for a single scalar output, and jax.jacrev refuses a complex-valued observable outright (raises TypeError without holomorphic=True). Numbers are never published here -- see the anti-rot rule in the module/CHANGELOG instead of trusting a docstring number, which rots.

    Parameters

    sim_fn : Callable params -> value, JAX-differentiable (typically lambda p: dft_field(name)(sim.forward(eps_override=eps(p), ...)) or the same composed with field_energy() / field_softmax()). Called under jax.jvp, so it must be traceable the same way any jax.grad/jax.jvp target is. params : Any A JAX pytree of the design variables to differentiate with respect to. With the default tangents="identity", every leaf must be a floating-dtype SCALAR (shape ()) -- see _identity_tangent_batch(). A flat (n_p,) array (one non-scalar leaf) needs the explicit tangents= escape hatch below. tangents : "identity" or a pytree, optional "identity" (default): build one one-hot tangent direction per scalar leaf of params (n_t = number of leaves); fails loud (ValueError) if any leaf is non-scalar or non-floating -- see _identity_tangent_batch()'s docstring for the measured trap this avoids. Otherwise: an explicit tangent BATCH -- a pytree with the same structure as params, every leaf carrying one extra LEADING axis of a common size n_t (e.g. tangents=jnp.eye(n_p) when params is a flat (n_p,) array, for the full Jacobian, or any (n_t, n_p) slice of it for a subset of columns). batch_tangents : bool, optional True (default): run all n_t tangent directions as ONE jax.vmap(jax.jvp(...)) call -- lower wall time (measured 2.3-3.1x faster than the sequential control at n_t in {4, 10}), higher peak memory (O(state) * (1 + n_t), all n_t tangent copies live at once). False: run the n_t directions as independent sequential jax.jvp calls in a plain Python loop -- the memory-vs-speed knob, trading the wall-time win for a peak that scales with ONE tangent copy at a time instead of n_t (measured: primal-sharing still holds sequentially, at ~1 + n_t compute but with a lower peak than the batched path scales to as n_t grows). CORRECTION: this function does not call jax.jit itself on EITHER path -- there is no jax.jit CALL anywhere in this module (verify: grep -nE '@jax\.jit|jax\.jit\(' rfx/observables.py returns nothing -- a plain grep jax.jit also matches this sentence's own prose, so it is not a useful self-check). batch_tangents=False runs the n_t sequential jax.jvp calls exactly as written, at whatever JIT boundary the CALLER supplies (eager if the caller does not jax.jit anything; compiled as part of one program if the caller wraps its own outer function, as scripts/benchmark_jacobian_fwd.py and tests/unit/autodiff/test_jacobian_fwd.py both do for compiled/jaxpr measurement) -- an earlier version of this docstring claimed jax.jit wrapping was part of this function's own behaviour, which was never true. The two paths (batched vs. sequential) are DIFFERENT XLA programs and may disagree at float32 reassociation scale (~1e-6 relative); they are not bit-identical. See "Fail-loud fences" below for how the two paths' SAFETY differs, not just their performance.

    Returns

    (value, jacobian) : tuple value: the single primal, sim_fn(params) bit-identical (verify this in your own test if you change sim_fn's structure -- see the API trap above). jacobian: a pytree mirroring value (sim_fn's output, NOT params -- see the paragraph above the signature), each leaf an array of shape (n_t,) + value.shape.

    Complex-Jacobian convention

    For a complex-valued value (e.g. dft_field()'s output), the returned Jacobian is dy/dx UNCONJUGATED -- exactly what forward-mode AD produces for a real input tangent and a complex output. This DIFFERS BY A CONJUGATE from anything derived through jax.vjp / jax.grad on the same computation (measured: 0.02% relative error against an unconjugated central-difference reference, 198% against the conjugated one). If you compare this Jacobian against anything built from reverse-mode AD, conjugate one side first. This is also why jax.jacrev cannot be used as a drop-in alternative here: it refuses a complex-dtype output outright (TypeError, needs holomorphic=True) -- forward mode is the only stock-JAX mode that hands back this Jacobian without a holomorphy declaration.

    Fail-loud fences

    Three configurations are unsafe to combine with jacobian_fwd on sim_fn bodies built on Simulation.forward(...). This function is intentionally generic over sim_fn (it never sees forward()'s own keyword arguments -- they live inside your closure), so the first two are enforced by a RAISE that propagates up through jax.jvp from forward()'s own pre-existing checks; the remaining one has no raise to inherit and cannot be intercepted from outside the closure, so it is a DOCUMENTED trap instead. Read the RAISES/DOCS tag on each before assuming "it didn't raise" means "it's fine":

    • [RAISES, inherited] non-uniform + distributed=True with a registered DFT-plane probe: forward() itself raises NotImplementedError (the #619 fence, rfx/api/_execute.py) -- neither sharded runner accumulates DFT-plane fields. jacobian_fwd scope is the uniform single-device lane only; a distributed sim_fn fails loud before this function's own machinery runs.
    • [RAISES, inherited] n_warmup: forward() itself raises NotImplementedError for a nonzero n_warmup on the uniform lane (issue #626) -- so on jacobian_fwd's supported (uniform) lane, a n_warmup-using sim_fn already fails loud. It used to be a measured SILENT NO-OP (bit-identical value AND tangent at n_warmup=0 vs n_warmup=60 of 80 steps) before the fence was added. Do not read this as "n_warmup is safe elsewhere": on the non-uniform lane where it IS implemented, n_warmup truncation error DEPENDS ON DISTANCE FROM THE SOURCE TO THE DESIGN REGION, not merely on n_warmup's value -- it is (near-)exact while n_warmup stays below the wavefront's arrival time at the design region (K_safe ~= floor(min_distance(source, design_region) / (C0 * dt))) and grows sharply beyond it, up to ~6-7% at half the pre-loss-window length, 58% at the loss-window boundary itself, exactly zero once far enough beyond it, FOR A NEAR-SOURCE placement specifically (issue #626 part 2 / addendum -- a far-from-source placement stays within this repo's ~1.5% AD-vs-FD noise floor for every n_warmup <= K_safe (growing smoothly, not a sharp cliff, from <0.01% well below K_safe to ~1.2% AT K_safe itself), see rfx/nonuniform.py's n_warmup split comment for the full measured curves and formula) -- it is not fenced there (truncated-BPTT is a legitimate, if placement-dependent, construction), and any future non-uniform extension of this function must account for K_safe, not treat n_warmup as uniformly lossy OR uniformly free.
    • [DOCS ONLY -- inert, not a raise] checkpoint / checkpoint_segments: measured EXACTLY NEUTRAL under forward mode (flops/temp bytes identical across checkpoint=False, checkpoint=True (the forward() default), and checkpoint_segments=K) -- remat only pays off under reverse-mode transposition, and forward mode builds no tape to transpose. A sim_fn built for use with jacobian_fwd should pass checkpoint=False explicitly (forward() defaults to checkpoint=True) to avoid inheriting dead HLO plus checkpoint_segments' n_steps % K == 0 divisibility raise for no forward-mode benefit. These are reverse-mode knobs; document them as such, do not reach for them here.

    Scope limits

    • Uniform single-device lane only (see the fences above).
    • "Geometry parameter" is NOT a parametric dimension here. A traced Box corner raises ConcretizationTypeError (rfx/geometry/csg.py) and forward()'s material rasterisation is a binary jnp.where mask with zero gradient almost everywhere. The only continuous geometry design channels this package has are topology density (via rfx.topology.density_to_material_fields()), pec_occupancy_override, and the non-uniform dz_profile. If you want d/d(patch width in metres), this function cannot supply that column -- rfx does not have it to give, on any AD mode.
    • Nonlinear (Kerr chi3) tangents ran clean under vmap(jvp) in testing but were numerically indistinguishable from the linear baseline at the tested chi3 -- that is NOT a validation of the nonlinear tangent path. Treat it as unverified until measured at a chi3 large enough to move the Jacobian outside noise.
    def parametric_sweep( sim_factory: Callable, param_name: str, param_values, *, n_steps: int | None = None, until_decay: float | None = None, run_kwargs: dict | None = None) -> SweepResult:

    Run multiple simulations with different parameter values.

    Parameters

    sim_factory : callable sim_factory(param_value) -> Simulation. Called once per value in param_values to create a fully configured Simulation object. param_name : str Human-readable name of the parameter being swept (used for labels). param_values : array-like 1-D sequence of parameter values to sweep over. n_steps : int or None If given, passed as n_steps to each sim.run(). until_decay : float or None If given, passed as until_decay to each sim.run(). run_kwargs : dict or None Additional keyword arguments forwarded to sim.run().

    Returns

    SweepResult Container with .results, .param_values, and .param_name.

    @dataclass
    class SweepResult:

    Structured result from a parametric sweep.

    Attributes

    results : list One rfx.api.Result (or equivalent) per parameter value. param_name : str Label for the swept parameter. param_values : ndarray 1-D array of parameter values that were swept.

    SweepResult(results: list, param_name: str, param_values: numpy.ndarray)
    results: list
    param_name: str
    param_values: numpy.ndarray
    def s11_min_db(self) -> numpy.ndarray:

    Return the minimum S11 (dB) for each sweep point.

    Works with results that have s_params (n_ports, n_ports, n_freqs).

    def peak_field(self) -> numpy.ndarray:

    Return peak absolute value of time-series for each sweep point.

    def pareto_front( self, metrics: Mapping[str, Sequence[float] | numpy.ndarray] | Sequence[str], *, minimize: bool | Sequence[bool] = True, ids: Sequence[str] | None = None, metadata: Sequence[Mapping[str, object] | None] | None = None, atol: float = 0.0) -> ParetoFront:

    Build a Pareto-front artifact from sweep metrics.

    metrics may be a mapping from objective name to one value per sweep point, or a sequence of SweepResult metric method names such as ("s11_min_db", "peak_field"). Maximization objectives are declared through minimize=False for that objective position.

    def plot_sweep( sweep_result: SweepResult, metric: str = 's11_min_db', *, title: str | None = None, ylabel: str | None = None, marker: str = 'o-') -> object:

    Visualize how a metric varies across the swept parameter.

    Parameters

    sweep_result : SweepResult Output of parametric_sweep. metric : str or callable Built-in metric name ("s11_min_db" or "peak_field"), or a callable metric(result) -> float applied to each result. title : str or None Plot title. Defaults to "<param_name> sweep" . ylabel : str or None Y-axis label. Defaults to the metric name. marker : str Matplotlib line/marker style (default "o-").

    Returns

    matplotlib Figure

    def vmap_material_sweep( sim, param_name: str, param_values, *, n_steps: int | None = None, num_periods: float = 20.0, return_fields: bool = False) -> VmapSweepResult:

    Batch-evaluate multiple material parameter values using jax.vmap.

    Only works for material parameters (eps_r, sigma, mu_r) where the grid shape stays constant. For geometry sweeps, use parametric_sweep().

    Parameters

    sim : Simulation Base simulation (all geometry already added). param_name : str Material parameter to sweep: "eps_r", "sigma", "mu_r", or a material-specific name like "substrate.eps_r". param_values : array-like, shape (n_batch,) Values to evaluate. n_steps : int or None Timesteps. If None, auto-computed from num_periods. num_periods : float Periods at freq_max for auto timestep count (default 20). return_fields : bool Must be False (the default). return_fields=True was documented here since this function's introduction but never implemented — neither the fast path nor the sequential fallback ever populated VmapSweepResult.final_fields, so it silently returned None instead of the promised snapshot. Passing True now raises ValueError (fail loud beats silent None, #578). Use sim.run()/sim.forward() for a final-field snapshot of one configuration, or parametric_sweep() for full per-value Result objects across a swept parameter.

    Returns

    VmapSweepResult Result with .time_series of shape (n_batch, n_steps, n_probes), .dft_planes (dict of (n_batch, n_freqs, n1, n2) complex arrays keyed by add_dft_plane_probe name, or None if no plane was registered), and .param_values.

    Notes

    The entire FDTD time loop is vmapped so all simulations execute in a single fused GPU kernel. Memory scales as n_batch * grid_size + n_batch * sum(n_freqs_p * n1_p * n2_p for each registered DFT plane p) (complex accumulators; no remat on this path). For large grids/plane counts, reduce batch size or plane frequency count to avoid OOM.

    Features supported in vmap path: PEC boundaries, CPML absorbing boundaries, soft sources (incl. amplitude_kind='current', #571), point probes, and DFT plane probes (add_dft_plane_probe, #578). Lumped ports, MSL/coaxial/floquet ports, TFSF, dispersion, waveguide ports, flux monitors, and NTFF are not supported in the vmap fast path and trigger a sequential fallback. As of #578 that fallback also populates .dft_planes (by stacking each swept value's Result.dft_planes accumulators), so registering a DFT plane never silently loses data on either path — it is only the FAST path that is conditional on eligibility, not the DFT-plane output.

    TFSF sweeps — angle batching is intentionally out of scope, not just unimplemented: it is a structural mismatch with this function's material-only batching model, not a missing feature. The TFSF incident field is itself an auxiliary FDTD solution advanced inside the scan (not closed-form); Method B's auxiliary-grid size is angle-dependent (a batch of angles would need a batch of scan shapes, which jax.vmap cannot express); and the oblique 2D-aux Bloch path concretizes its transverse phase factors from angle_deg at trace time (rfx/sources/tfsf_2d.py). More basically, rfx cannot even express a general incidence triple (theta, phi, psi) today — only angle_deg + a binary direction + a two-way polarization choice — so there is no batchable angle parameter to expose in the first place. Route illumination/angle sweeps to parametric_sweep() instead: it already returns full Result objects including dft_planes for each swept value. Caveat there too: oblique-Bloch TFSF (angle_deg != 0, method='bloch', the default) combined with DFT planes/flux monitors/NTFF raises NotImplementedError by design (rfx/simulation.py:728-745) because the accumulated envelope is not the physical spectrum — DFT-plane tensors from a TFSF-illuminated parametric_sweep are obtainable only at normal incidence (angle_deg=0) or with method='methodB' (open-domain, real fields). A MATERIAL sweep on a sim carrying a FIXED (non-swept) TFSF source still benefits from #578: it takes this function's sequential fallback (TFSF is not fast-path-eligible) but that fallback now carries dft_planes through, so per-material DFT tensors at fixed incidence ARE obtainable via vmap_material_sweep today.

    Point-frequency-domain probes are not a separate case here: rfx has no public builder for a point DFT probe (only plane DFT probes and flux monitors), and point time series — the actual point-probe primitive — are already fully batched via add_probe + .time_series on both paths.

    @dataclass
    class VmapSweepResult:

    Result from a vmapped material sweep.

    Attributes

    time_series : ndarray, shape (n_batch, n_steps, n_probes) Probe recordings for every sweep value. param_name : str Name of the swept parameter. param_values : ndarray, shape (n_batch,) The parameter values that were swept. final_fields : dict or None Never populated (return_fields=True raises ValueError instead of silently returning None here — see vmap_material_sweep). Reserved for a future final-field-state implementation; use sim.run()/sim.forward() or parametric_sweep() for final fields today. dft_planes : dict[str, ndarray] or None DFT plane accumulators, keyed by the add_dft_plane_probe name, each shaped (n_batch, n_freqs, n1, n2) complex — a leading batch axis over the same accumulator Result.dft_planes[name] exposes for a single run (#578). Populated on BOTH the vmap fast path and the sequential fallback (the fallback stacks each swept-value Result.dft_planes[name].accumulator), so the field is uniform regardless of which path a given Simulation takes. None if no add_dft_plane_probe was registered. Appended AFTER final_fields (not inserted before it) to preserve the positional-construction meaning of this publicly exported dataclass for any existing caller using positional args.

    VmapSweepResult( time_series: numpy.ndarray, param_name: str, param_values: numpy.ndarray, final_fields: dict | None = None, dft_planes: dict | None = None)
    time_series: numpy.ndarray
    param_name: str
    param_values: numpy.ndarray
    final_fields: dict | None = None
    dft_planes: dict | None = None
    def peak_field(self) -> numpy.ndarray:

    Return peak |probe value| per batch element.

    class ParameterSweep:

    Define a multi-dimensional parameter sweep.

    Parameters are given as keyword arguments, each mapping to an array of values. The sweep iterates over the full Cartesian product.

    Example

    >>> sweep = ParameterSweep(width=[0.01, 0.02], eps_r=[2.0, 4.0])
    >>> sweep.total
    4
    >>> list(sweep.combinations())
    [{'width': 0.01, 'eps_r': 2.0}, {'width': 0.01, 'eps_r': 4.0}, ...]
    
    ParameterSweep(**params)
    keys: list[str]
    total: int
    def combinations(self):

    Iterate over all parameter combinations as dicts.

    @dataclass(frozen=True)
    class BatchCaseResult:

    Result record returned by run_batch_with_manifest().

    result is None for cases skipped through resume.

    BatchCaseResult( case_id: str, params: dict[str, typing.Any], status: str, metrics: dict[str, typing.Any], artifacts: dict[str, str], result: Any = None, error: str | None = None, skipped: bool = False)
    case_id: str
    params: dict[str, typing.Any]
    status: str
    metrics: dict[str, typing.Any]
    artifacts: dict[str, str]
    result: Any = None
    error: str | None = None
    skipped: bool = False
    def run_batch( sim_factory: Callable[..., Any], sweep: ParameterSweep, *, run_kwargs: dict | None = None) -> list[tuple[dict, typing.Any]]:

    Run simulations for all parameter combinations.

    Parameters

    sim_factory : callable Function that takes keyword args from the sweep and returns a configured Simulation object (or any object with a .run() method). sweep : ParameterSweep Parameter sweep definition. run_kwargs : dict or None Extra keyword arguments passed to sim.run().

    Returns

    list of (params_dict, result) tuples

    def run_batch_with_manifest( sim_factory: Callable[..., Any], sweep: ParameterSweep, output_dir: str | pathlib.Path, *, run_kwargs: dict | None = None, metric_fn: Optional[Callable[[Any], dict[str, Any]]] = None, artifact_fn: Optional[Callable[[pathlib.Path, dict[str, Any], Any], dict[str, str | pathlib.Path] | None]] = None, resume: bool = True, continue_on_error: bool = False, manifest_name: str = 'manifest.json', run_fingerprint: str | None = None, include_environment: bool = True) -> list[BatchCaseResult]:

    Run a sequential sweep with durable per-case provenance.

    The runner records stable case ids, parameters, status, timings, metrics, and optional artifact paths. artifact_fn may return paths relative to the case directory, paths relative to the output root, or absolute paths inside the output root. The runner automatically fingerprints run_kwargs so changing run settings (for example n_steps) invalidates resume. Pass run_fingerprint when simulation factory, metric semantics, or external inputs changed but run_kwargs did not. When resume=True, a case is skipped only if the manifest record is completed, has the expected run fingerprint, contains at least one metric, and all declared artifact paths still exist. With continue_on_error=True, failed cases are recorded and later cases continue; the default preserves the fail-fast behavior.

    def summarize_batch_manifest( manifest_or_path: dict[str, typing.Any] | str | pathlib.Path) -> dict[str, typing.Any]:

    Return an inspectable status summary for a batch manifest.

    manifest_or_path may be a manifest dictionary or a path to a manifest JSON file. The returned summary is detached from the input so callers can use it for reports without mutating the manifest.

    def wire_port_voltage(state, grid, port, pec_edge_masks=None) -> jax.jaxlib._jax.Array:

    Voltage at the WirePort LIVE-run midpoint cell.

    Uses a single-cell measurement at the live-run midpoint (same location as wire_port_current) for balanced V/I wave decomposition. The midpoint is that of the LIVE run (issue #764; pec_edge_masks=None or no dead cells is bit-identical to the historical all-extent midpoint).

    Parameters

    state : FDTDState grid : Grid port : WirePort pec_edge_masks : realized PEC edge masks (Mx, My, Mz) for the #318 live split

    Returns

    float scalar

    def wire_port_current( state, grid, port, periodic=(False, False, False), pec_edge_masks=None) -> jax.jaxlib._jax.Array:

    Current through a WirePort via Ampere's law at the LIVE-run midpoint.

    Uses the H-field loop integral at the center cell of the LIVE wire run (issue #764; with no dead cells this is the historical all-extent midpoint), the same _ampere_loop() port_current() uses. Out-of-domain H is ZERO (#692) — see _bwd_h() for the rule and for the two axis kinds that keep the wrap.

    Parameters

    state : FDTDState grid : Grid port : WirePort periodic : (bool, bool, bool) Per-axis periodic flags for the run; default non-periodic. pec_edge_masks : realized PEC edge masks (Mx, My, Mz) for the #318 live split

    Returns

    float scalar

    class FluxMonitor(typing.NamedTuple):

    Running DFT accumulator for Poynting flux through a plane.

    Accumulates frequency-domain E and H tangential components on a (possibly finite-size) region of the plane to compute ∫ Re(E × H*) · n̂ dA at each frequency.

    For x-normal plane: flux = Re(Ey·Hz* - Ez·Hy*) integrated over y,z. For y-normal plane: flux = Re(Ez·Hx* - Ex·Hz*) integrated over x,z. For z-normal plane: flux = Re(Ex·Hy* - Ey·Hx*) integrated over x,y.

    FluxMonitor( e1_dft: ForwardRef('jnp.ndarray'), e2_dft: ForwardRef('jnp.ndarray'), h1_dft: ForwardRef('jnp.ndarray'), h2_dft: ForwardRef('jnp.ndarray'), freqs: ForwardRef('jnp.ndarray'), axis: int, index: int, dA: ForwardRef('jnp.ndarray'), total_steps: int, window: str, window_alpha: float, lo1: int = 0, hi1: int = -1, lo2: int = 0, hi2: int = -1, dA2: ForwardRef('jnp.ndarray | None') = None)

    Create new instance of FluxMonitor(e1_dft, e2_dft, h1_dft, h2_dft, freqs, axis, index, dA, total_steps, window, window_alpha, lo1, hi1, lo2, hi2, dA2)

    e1_dft: jax.jaxlib._jax.Array

    Alias for field number 0

    e2_dft: jax.jaxlib._jax.Array

    Alias for field number 1

    h1_dft: jax.jaxlib._jax.Array

    Alias for field number 2

    h2_dft: jax.jaxlib._jax.Array

    Alias for field number 3

    freqs: jax.jaxlib._jax.Array

    Alias for field number 4

    axis: int

    Alias for field number 5

    index: int

    Alias for field number 6

    dA: jax.jaxlib._jax.Array

    Alias for field number 7

    total_steps: int

    Alias for field number 8

    window: str

    Alias for field number 9

    window_alpha: float

    Alias for field number 10

    lo1: int

    Alias for field number 11

    hi1: int

    Alias for field number 12

    lo2: int

    Alias for field number 13

    hi2: int

    Alias for field number 14

    dA2: jax.jaxlib._jax.Array | None

    Alias for field number 15

    def flux_spectrum( mon: FluxMonitor, *, exact_f64: bool = False) -> jax.jaxlib._jax.Array:

    Compute Poynting flux spectrum from accumulated DFT fields.

    Returns

    flux : (n_freqs,) float ∫ Re(E × H*) · n̂ dA at each frequency. Positive = power flowing in +axis direction.

    Notes

    With x64 disabled the accumulators are complex64, and per-cell E×H* products below the float32 minimum normal (~1.18e-38) are flushed to zero by XLA — a physically tiny-but-nonzero flux then returns EXACTLY 0.0 at every frequency with healthy field accumulators (issue #304; single-cell fixed-amplitude sources radiate P ~ dx^6, so fine grids hit this readily). Eager calls detect that state with a float64 NumPy recompute of the identical sum and emit a UserWarning; under jit/grad the check is skipped entirely (tracer-safe, off the AD tape).

    exact_f64=True performs that float64 NumPy recompute AS the result (eager-only; raises under tracing): the per-cell products and the sum run in complex128 from the (healthy) float32 accumulators, which is the #304 warning's own sanctioned remedy. Absolute-scale energy consumers (the #589 flux-adjudication witness channel) use this path — partial subnormal flushing corrupts small-magnitude sums well before they reach exact zero, so the warning alone is not sufficient there. The default path is unchanged.

    def subtract_flux_monitors( sample: FluxMonitor, ref: FluxMonitor) -> FluxMonitor:

    Remove a reference (incident) field from a flux monitor, field-level.

    The two-run reference subtraction of docs/agent/recipe-rt-measurement.mdx (critical detail (a)): pass the monitor from the run WITH the scatterer and the monitor from the otherwise identical run WITHOUT it, and call flux_spectrum() on the result to get the scattered flux.

    Parameters

    sample : FluxMonitor Monitor from the run with the scatterer present. ref : FluxMonitor Monitor from the reference run — same plane, same frequencies.

    Returns

    FluxMonitor sample with each of the four tangential DFT accumulators replaced by sample.<field> - ref.<field>. Every other field (freqs, axis, index, dA, total_steps, window, window_alpha, lo1/hi1/lo2/hi2) is carried over from sample unchanged.

    Raises

    TypeError If either argument is not a FluxMonitor. ValueError If the four accumulators disagree in shape, or if any non-accumulator field differs. Nothing is broadcast: two monitors that do not describe the same plane and the same frequencies have no meaningful difference, and a silent broadcast would return a plausible-looking number instead of an error.

    Notes

    The subtraction has to happen on the FIELDS, never on the fluxes. Poynting flux is bilinear in E and H, so writing the sample fields as incident plus scattered (E_s = E_i + E_r, H_s = H_i + H_r) gives

    ``flux(sample) - flux(ref) = flux(scattered)
                                 + Re(E_i x H_r* + E_r x H_i*) dA``
    

    and those cross terms are the same order as the scattered flux itself — they vanish only for a null scatterer. Maxwell's equations are linear, so differencing the accumulators first and forming the Poynting product afterwards is exact. flux_spectrum(sample) - flux_spectrum(ref) is not an approximation of this; it is a different quantity.

    Tracer-safe: the subtraction is plain array arithmetic and stays on the AD tape, so jax.grad through flux_spectrum(subtract_flux_monitors(...)) works. Shape checks run on tracers too; the equality check on array-valued metadata is undecidable under jit/grad and is skipped there.

    def harminv( signal: numpy.ndarray, dt: float, f_min: float, f_max: float, *, pencil_parameter: float = 0.33, min_Q: float = 1.0, max_modes: int = 50, sv_threshold: float = 0.001, decimate: str | bool = 'auto') -> list[HarminvMode]:

    Extract resonant modes via the Matrix Pencil Method.

    Lossless closed cavities have infinite physical Q — do not read a Q off a finite window. In a PEC-bounded domain with no material loss (an empty or lossless-filled cavity), a resonance never decays, so the Q returned here is a pure windowing artefact: it swings by orders of magnitude with the run length rather than tracking any physics. The resonant frequency is still meaningful; the Q is not. To measure a meaningful Q, add a realistic loss (a finite sigma / tan_delta on the fill, or a lossy load) so the cavity has a finite physical Q, or use an open (CPML) boundary so radiation sets the Q.

    Parameters

    signal : 1D real or complex array Time-domain signal. dt : float Timestep in seconds. f_min, f_max : float Frequency search range in Hz. pencil_parameter : float Fraction of signal length for pencil size L (0.2-0.5). min_Q : float Discard modes with Q < min_Q. max_modes : int Maximum complex-pole rank and maximum modes to return. Both members of a real signal's conjugate pair consume rank before reporting. sv_threshold : float Relative singular value threshold for rank determination (relative to the largest singular value). The same threshold is used after resampling; it is not a calibrated noise-confidence bound. decimate : {"auto", False} Automatically reduce oversampled, band-limited inputs before matrix pencil analysis. With the default "auto", decimation is applied when 1 / dt > 8 * f_max using a target factor of int(1 / dt / (4 * f_max)). Anti-aliased, zero-phase FIR stages of at most 13 are used, and the effective timestep is passed to the core algorithm. At each stage, outputs touching the filter's zero-padded boundary are excluded: filtering an interior exponential preserves its pole, while zero padding introduces transients. This SHORTENS the usable record; harminv_record_duration reports its duration. A stage is omitted if fewer than 10 interior samples would remain or its pencil could not hold max_modes poles plus one singular direction for rank selection. If the original pencil is smaller, its existing capacity is preserved instead. This is a dimension safeguard, not a guarantee that noisy/nearby modes are identifiable. Set to False to retain every input sample. Decimation avoids redundant work because the estimator scales approximately as \( O(N^{2.7}) \).

    Returns

    list of HarminvMode, sorted by amplitude (strongest first). Amplitude and phase describe jointly fitted complex coefficients at the original input's first sample. For a real cosine of peak A the positive-frequency coefficient has magnitude A/2; its conjugate is fitted too, then omitted from the returned list. Relative weights depend on source/probe placement and are not modal energies.

    def harminv_from_probe( time_series: numpy.ndarray, dt: float, freq_range: tuple[float, float], *, source_decay_time: float = 0.0, **kwargs) -> list[HarminvMode]:

    Extract resonances from a simulation probe time series.

    Automatically windows the signal to use only the ring-down portion (after source has decayed).

    A lossless closed (PEC) cavity has infinite physical Q, so the Q returned here is a window-length artefact, not physics — add a realistic loss or use an open (CPML) boundary before trusting a Q. See harminv() for the full note. (The frequency is fine.)

    Parameters

    time_series : 1D array dt : float freq_range : (f_min, f_max) in Hz source_decay_time : float Time at which source has decayed (seconds).

    class HarminvMode(typing.NamedTuple):

    A single resonant mode extracted by Harminv.

    HarminvMode( freq: float, decay: float, Q: float, amplitude: float, phase: float, error: float)

    Create new instance of HarminvMode(freq, decay, Q, amplitude, phase, error)

    freq: float

    Alias for field number 0

    decay: float

    Alias for field number 1

    Q: float

    Alias for field number 2

    amplitude: float

    Alias for field number 3

    phase: float

    Alias for field number 4

    error: float

    Alias for field number 5

    class WaveguideMode(typing.NamedTuple):

    A single waveguide eigenmode on the cross-section grid.

    Attributes

    ey_profile : ndarray, shape (nu, nv) Transverse E-field, first local axis component. ez_profile : ndarray, shape (nu, nv) Transverse E-field, second local axis component. hy_profile : ndarray, shape (nu, nv) Transverse H-field, first local axis component. hz_profile : ndarray, shape (nu, nv) Transverse H-field, second local axis component. beta : ndarray, shape (n_freqs,) Propagation constant at each frequency. Real above cutoff, imaginary (stored as complex) below cutoff. f_cutoff : float Cutoff frequency in Hz. mode_type : str "TE" or "TM". mode_indices : tuple[int, int] (m, n) mode indices.

    WaveguideMode( ey_profile: ForwardRef('np.ndarray'), ez_profile: ForwardRef('np.ndarray'), hy_profile: ForwardRef('np.ndarray'), hz_profile: ForwardRef('np.ndarray'), beta: ForwardRef('np.ndarray'), f_cutoff: float, mode_type: str, mode_indices: tuple[int, int])

    Create new instance of WaveguideMode(ey_profile, ez_profile, hy_profile, hz_profile, beta, f_cutoff, mode_type, mode_indices)

    ey_profile: numpy.ndarray

    Alias for field number 0

    ez_profile: numpy.ndarray

    Alias for field number 1

    hy_profile: numpy.ndarray

    Alias for field number 2

    hz_profile: numpy.ndarray

    Alias for field number 3

    beta: numpy.ndarray

    Alias for field number 4

    f_cutoff: float

    Alias for field number 5

    mode_type: str

    Alias for field number 6

    mode_indices: tuple[int, int]

    Alias for field number 7

    def solve_waveguide_modes( a: float, b: float, dx: float, freqs: numpy.ndarray, n_modes: int = 1, eps_cross: numpy.ndarray | None = None, mu_cross: numpy.ndarray | None = None) -> list[WaveguideMode]:

    Solve for the lowest eigenmodes of a rectangular waveguide cross-section.

    Parameters

    a : float Waveguide width (metres), along the first transverse axis. b : float Waveguide height (metres), along the second transverse axis. dx : float Cell size (metres). freqs : array_like, shape (n_freqs,) Frequency points at which to evaluate the propagation constant. n_modes : int Number of lowest-cutoff modes to return. eps_cross : ndarray, shape (ny, nz), optional Relative permittivity on the cross-section grid. None means uniform vacuum (eps_r = 1). mu_cross : ndarray, shape (ny, nz), optional Relative permeability on the cross-section grid. None means uniform (mu_r = 1).

    Returns

    modes : list[WaveguideMode] The n_modes lowest-cutoff eigenmodes, sorted by cutoff frequency.

    @dataclass(frozen=True)
    class LumpedRLCSpec:

    Lumped RLC element specification.

    Parameters

    R : float Resistance in ohms. 0 = no resistive component. L : float Inductance in henries. 0 = no inductive component. C : float Capacitance in farads. 0 = no capacitive component. topology : str "series" or "parallel". Series topology tracks a shared current through R, L, C via ADE (true series RLC). Parallel topology folds R/C into material properties independently and uses an ADE only for L. position : (x, y, z) in metres component : str E-field component ("ex", "ey", or "ez").

    LumpedRLCSpec( R: float = 0.0, L: float = 0.0, C: float = 0.0, topology: str = 'series', position: tuple[float, float, float] = (0.0, 0.0, 0.0), component: str = 'ez')
    R: float = 0.0
    L: float = 0.0
    C: float = 0.0
    topology: str = 'series'
    position: tuple[float, float, float] = (0.0, 0.0, 0.0)
    component: str = 'ez'
    def auto_configure( geometry: list, freq_range: tuple[float, float], materials: dict | None = None, accuracy: str = 'standard', *, boundary: str = 'cpml', dx_override: float | None = None, margin_override: float | None = None, n_steps_override: int | None = None, max_memory_mb: float | None = None, waveform=None, thin_conductors=()) -> SimConfig:

    Derive all simulation parameters from geometry + frequency range.

    Parameters

    geometry : list of (Shape, material_name) tuples freq_range : (f_min, f_max) in Hz materials : dict of material definitions (name -> {eps_r, sigma, ...}) accuracy : "draft", "standard", or "high" draft: 10 cells/λ, 0.15λ margin (fast, ~10% error) standard: 20 cells/λ, 0.25λ margin (~3% error) high: 40 cells/λ, 0.50λ margin (~1% error) boundary : "cpml" or "pec" Boundary condition type. Determines source type recommendation: - "cpml": J-source with Cb/dV normalization (Meep convention) - "pec": raw E-field source (no Cb normalization needed) dx_override : force specific cell size margin_override : force specific margin n_steps_override : force specific step count max_memory_mb : float or None GPU memory budget in megabytes. When set, dx is automatically coarsened (increased) until estimated_memory_mb <= max_memory_mb. Useful for keeping reverse-mode AD within a 24 GB GPU. Ignored when dx_override is provided.

    Practical limits (approximate, with gradient checkpointing):
    
    - 8 GB GPU:  ``max_memory_mb=6000``
    - 24 GB GPU: ``max_memory_mb=18000``
    - 48 GB GPU: ``max_memory_mb=36000``
    

    waveform : source waveform or None Used only for the source-time term of the n_steps estimate: when the waveform exposes t0 (GaussianPulse / ModulatedGaussian: t0 = cutoff*tau), the source window is 2*t0 so a longer-onset pulse (e.g. cutoff=4.5 → 9*tau) is fully covered. Without a waveform the historical cutoff=3 envelope (6*tau at bw=0.8) is used, bitwise-identical to the previous hardcode. thin_conductors : iterable of ThinConductor Declared sheets. Their planes (and any zero-thickness PEC box in geometry) become z FEATURES: the non-uniform z profile puts a mesh line on each, so a sheet is realized where it was declared (#931 §1.9).

    Returns

    SimConfig with all derived parameters.

    @dataclass
    class SimConfig:

    Auto-derived simulation configuration.

    SimConfig( dx: float, domain: tuple[float, float, float], cpml_layers: int, n_steps: int, freq_range: tuple[float, float], margin: float, dt: float, accuracy: str, source_type: str = 'j_source', source_info: str = '', warnings: list[str] = <factory>, dz_profile: numpy.ndarray | None = None, boundary: str = 'cpml')
    dx: float
    domain: tuple[float, float, float]
    cpml_layers: int
    n_steps: int
    freq_range: tuple[float, float]
    margin: float
    dt: float
    accuracy: str
    source_type: str = 'j_source'
    source_info: str = ''
    warnings: list[str]
    dz_profile: numpy.ndarray | None = None
    boundary: str = 'cpml'
    grid_shape: tuple[int, int, int]

    Estimated grid shape (nx, ny, nz) including CPML padding.

    Issue #696: when dz_profile is set the z axis is counted from the PROFILE, not from ceil(domain_z / dx). This property described the UNIFORM grid even for a configuration whose whole point is a graded z stackup, and a graded stackup normally refines below dx — so the cell count, and every memory number derived from it, came out LOW for exactly the meshes people size GPUs for. The z term now matches NonUniformGrid's len(profile) + pads + 1.

    estimated_memory_mb: float

    Coarse auto-configuration memory heuristic in megabytes.

    This property is used while choosing a draft mesh from geometry before a Simulation object exists. It intentionally remains a conservative planning heuristic, not the calibrated AD-memory contract for production runs.

    For user-facing memory reports and inverse-design sizing, prefer Simulation.estimate_ad_memory(...) or Simulation.mesh_intelligence_report(...). Those APIs know the final grid/profiles, NTFF state, and segmented scan-of-scan checkpointing semantics from issue #31.

    Accounts for:

    • 6 field arrays (Ex, Ey, Ez, Hx, Hy, Hz)
    • ~6 material coefficient arrays (eps_r, sigma, mu_r, Ca, Cb, etc.)
    • CPML auxiliary fields (~24 psi arrays in absorbing region)
    • NTFF DFT accumulators (6 faces × n_freqs × face_cells × 4 components)
    • Debye/Lorentz auxiliary polarization fields (2 per pole)
    • ~10x overhead for reverse-mode AD through jax.lax.scan (XLA compilation buffers, reverse-mode tape for scan unrolling, CPML auxiliary arrays replicated for AD, jax.checkpoint reduces but does not eliminate this)

    Returns

    float Estimated memory in megabytes.

    cells_per_wavelength: float
    sim_time_ns: float
    uses_nonuniform: bool
    def to_sim_kwargs(self) -> dict:

    Convert to keyword arguments for Simulation constructor.

    def summary(self) -> str:
    def analyze_features( geometry: list, materials: dict, pec_threshold: float = 1000000.0, thin_conductors=()) -> rfx.auto_config.FeatureInfo:

    Extract critical dimensions and material properties from geometry.

    Parameters

    geometry : list of (Shape, material_name) tuples materials : dict of material_name -> MaterialSpec or dict pec_threshold : sigma above which material is PEC thin_conductors : iterable of ThinConductor Declared sheets (sim.add_thin_conductor). Their planes join the zero-thickness PEC boxes found in geometry as z FEATURES (#931 §1.9): a sheet has no thickness to resolve, but the mesh must carry a node at its plane.

    @dataclass(frozen=True)
    class MeshPlan:

    Inspectable mesh-planning result built from existing rfx facts.

    MeshPlan is an advisory artifact. It serializes the derived mesh, CFL basis, memory estimate, support rows, and intended artifact paths without writing files or claiming deterministic solver replay.

    MeshPlan( schema_version: str, plan_source: str, freq_range: tuple[float | None, float], accuracy: str | None, boundary: str, cell_sizes: dict[str, typing.Any], grid_shape: tuple[int, int, int], domain: tuple[float, float, float], margin: float | None, cfl: dict[str, typing.Any], absorber: dict[str, typing.Any], resolution_basis: list[dict[str, typing.Any]], support_checks: list[dict[str, typing.Any]], memory: dict[str, typing.Any], artifact_declarations: dict[str, dict[str, typing.Any]], warnings: tuple[str, ...] = (), recommendation: str = '', _sim_config: SimConfig | None = None)
    schema_version: str
    plan_source: str
    freq_range: tuple[float | None, float]
    accuracy: str | None
    boundary: str
    cell_sizes: dict[str, typing.Any]
    grid_shape: tuple[int, int, int]
    domain: tuple[float, float, float]
    margin: float | None
    cfl: dict[str, typing.Any]
    absorber: dict[str, typing.Any]
    resolution_basis: list[dict[str, typing.Any]]
    support_checks: list[dict[str, typing.Any]]
    memory: dict[str, typing.Any]
    artifact_declarations: dict[str, dict[str, typing.Any]]
    warnings: tuple[str, ...] = ()
    recommendation: str = ''
    def to_dict(self) -> dict[str, typing.Any]:

    Return a stable JSON-serializable representation.

    def to_json(self, **kwargs: Any) -> str:

    Serialize the plan for audit artifacts.

    def to_markdown(self) -> str:

    Render a compact human-readable planning report.

    def to_sim_config(self) -> SimConfig:

    Return the underlying SimConfig when built by plan_mesh.

    def to_sim_kwargs(self) -> dict[str, typing.Any]:

    Return Simulation constructor kwargs when built by plan_mesh.

    def plan_mesh( geometry: list, freq_range: tuple[float, float], materials: dict | None = None, accuracy: str = 'standard', *, boundary: str = 'cpml', dx_override: float | None = None, margin_override: float | None = None, n_steps_override: int | None = None, max_memory_mb: float | None = None, artifact_root: str | pathlib.Path | None = None) -> MeshPlan:

    Plan a mesh from geometry and frequency inputs using auto_configure.

    This function is a facade around auto_configure; it does not duplicate the mesh preset math. The returned MeshPlan adds structured audit rows, artifact declarations, and serialization helpers. artifact_root only declares intended paths; it never creates directories or writes files.

    def plan_simulation_mesh( sim: Any, *, n_steps: int | None = None, checkpoint_every: int | None = None, available_memory_gb: float | None = None, sparameter_calculator: str | None = None, artifact_root: str | pathlib.Path | None = None) -> MeshPlan:

    Plan/audit a configured Simulation using existing preflight APIs.

    artifact_root only declares intended paths; it never creates directories or writes files.

    sparameter_calculator="waveguide" is the one calculator whose preflight is not free: it builds the grid and solves each waveguide port's mode so the setup audits can read the runner's own plane indices and cutoffs. It runs no FDTD, but on a fine mesh with several ports it is seconds rather than milliseconds. Those audits also file an informational row per port pair (the known E-plane offset), which is a status="info" row, not a "warn" one.

    def convergence_study( sim_factory: Callable, dx_values, metric_fn: Callable, *, n_steps: int | None = None, until_decay: float | None = None, run_kwargs: dict | None = None, expected_order: float = 2.0) -> ConvergenceResult:

    Run a mesh convergence study.

    Parameters

    sim_factory : callable(dx) -> Simulation Builds a simulation at the given cell size. dx_values : list of float Cell sizes to test (coarse to fine). metric_fn : callable(Result) -> float Extracts the metric of interest (e.g., resonance frequency). n_steps : int or None If given, passed as n_steps to each sim.run(). until_decay : float or None If given, passed as until_decay to each sim.run(). run_kwargs : dict or None Additional keyword arguments forwarded to sim.run(). expected_order : float Initial guess for convergence order (default 2 for standard Yee).

    Returns

    ConvergenceResult Container with dx_values, metrics, extrapolated value, order, relative errors, and raw results.

    @dataclass
    class ConvergenceResult:

    Structured result from a mesh convergence study.

    Attributes

    dx_values : ndarray Cell sizes tested (coarse to fine). metrics : ndarray Metric value at each cell size. extrapolated : float Richardson-extrapolated grid-independent value. order : float Estimated convergence order. errors : ndarray Relative error of each metric w.r.t. the extrapolated value. results : list Raw simulation results (one per dx).

    ConvergenceResult( dx_values: numpy.ndarray, metrics: numpy.ndarray, extrapolated: float, order: float, errors: numpy.ndarray, results: list)
    dx_values: numpy.ndarray
    metrics: numpy.ndarray
    extrapolated: float
    order: float
    errors: numpy.ndarray
    results: list
    def plot(self, *, ax=None, title: str = 'Mesh Convergence'):

    Plot convergence: metric vs dx and log-log error vs dx.

    Parameters

    ax : matplotlib Axes or None If provided, only the metric-vs-dx plot is drawn on this axes. If None, a two-panel figure is created. title : str Figure/axes title.

    Returns

    matplotlib Figure

    def summary(self) -> str:

    Return a formatted convergence summary table.

    Returns

    str Multi-line summary table.

    def richardson_extrapolation(values, dx_values, expected_order: float = 2.0) -> tuple[float, float]:

    Richardson extrapolation to estimate the grid-independent value.

    Uses the three finest grid points to estimate both the converged value and the effective convergence order. Falls back to two-point extrapolation when only two values are available.

    Parameters

    values : array-like Metric values at each cell size (must have >= 2 elements). dx_values : array-like Cell sizes corresponding to values (same length). expected_order : float Initial guess for the convergence order (default 2 for Yee).

    Returns

    (extrapolated, order) : (float, float) Estimated grid-independent value and observed convergence order.

    Notes

    When three or more values are available the order p is estimated by solving the ratio of successive differences:

    (f3 - f2) / (f2 - f1) = (h3^p - h2^p) / (h2^p - h1^p)
    

    where h = dx and f = metric. A Newton iteration refines p from the initial guess. With only two values the expected_order is used directly.

    def quick_convergence( sim: object, *, metric: Union[str, Callable] = 'resonance', dx_factors: list[float] | None = None, n_steps: int | None = None, until_decay: float | None = None, run_kwargs: dict | None = None) -> ConvergenceResult:

    Run a convergence study on an existing Simulation with dx scaling.

    Creates copies of sim at different cell sizes by scaling the base dx and re-running. This is a convenience wrapper around convergence_study for quick checks.

    Parameters

    sim : rfx.api.Simulation A fully configured simulation. Its _dx (or auto-computed dx) is used as the reference cell size scaled by each factor. metric : str or callable "resonance" extracts the first resonance frequency. "peak_field" extracts the peak probe amplitude. A callable metric(Result) -> float is used directly. dx_factors : list of float or None Scaling factors applied to the base dx. Default [2.0, 1.5, 1.0, 0.75]. Values > 1 mean coarser; < 1 mean finer. n_steps : int or None Forwarded to sim.run(). until_decay : float or None Forwarded to sim.run(). run_kwargs : dict or None Additional keyword arguments forwarded to sim.run().

    Returns

    ConvergenceResult

    def compute_error_indicator(result, *, component: str = 'ez') -> numpy.ndarray:

    Compute spatial error indicator from a simulation result.

    Returns a 3-D array where high values indicate regions that would benefit from mesh refinement. The proxy is the magnitude of the spatial gradient of the final-state field component, normalized to [0, 1].

    Parameters

    result : Result or SimResult A completed simulation result whose .state carries the final field arrays (ex, ey, ez, hx, hy, hz). component : str Field component to analyze (default "ez").

    Returns

    error : ndarray, shape (Nx, Ny, Nz) Normalized error indicator in [0, 1]. Values near 1 mark cells with the steepest field gradients.

    def suggest_refinement_regions( error_map: numpy.ndarray, *, grid=None, threshold: float = 0.5, min_region_size: int = 4) -> list[Box]:

    Suggest Box regions for SBP-SAT refinement based on error map.

    Identifies connected regions where the error indicator exceeds threshold and returns axis-aligned bounding boxes for each region that is at least min_region_size cells in every used dimension.

    Parameters

    error_map : ndarray, shape (Nx, Ny, Nz) Normalized error indicator (output of compute_error_indicator). grid : Grid or None If provided, box corners are in physical (metre) coordinates. Otherwise they are in cell-index coordinates. threshold : float Cells with error_map > threshold are flagged (default 0.5). min_region_size : int Minimum bounding-box extent in cells per axis to keep a region (default 4). Smaller regions are discarded as noise.

    Returns

    list of Box Suggested refinement bounding boxes.

    def auto_refine( sim, result, *, threshold: float = 0.5, component: str = 'ez', min_region_size: int = 1, ratio: int = 2):

    Automatically add a refinement region based on the error indicator.

    Computes the error indicator from result, finds the dominant high- error region, and calls sim.add_refinement() with the corresponding z-range.

    Parameters

    sim : Simulation The simulation to add refinement to (mutated in-place). result : Result Completed coarse simulation result. threshold : float Error threshold for region suggestion (default 0.5). component : str Field component for the error indicator (default "ez"). min_region_size : int Minimum cells per axis for a suggested region (default 2). ratio : int Subgrid refinement ratio passed to add_refinement() (default 2).

    Returns

    sim : Simulation The same simulation object (for chaining).

    def deembed_port_extension( s_matrix: numpy.ndarray, freqs: numpy.ndarray, port_lengths: list[float] | numpy.ndarray, z0: float = 50.0, eps_eff: float | numpy.ndarray = 1.0) -> numpy.ndarray:

    Remove port extension (transmission line) effects from S-parameters.

    Shifts the reference plane inward by removing the phase delay of the feed lines at each port. Assumes lossless, matched feed lines (same impedance as the port reference impedance) so only a phase correction is needed.

    Parameters

    s_matrix : ndarray, shape (n_ports, n_ports, n_freqs) Complex S-parameter matrix. freqs : ndarray, shape (n_freqs,) Frequency points in Hz. port_lengths : list of float or ndarray, shape (n_ports,) Physical length to de-embed from each port, in metres. z0 : float Port reference impedance (ohm). Not used in the phase-only correction but kept for API consistency and future lossy extension. eps_eff : float or ndarray Effective relative permittivity of the feed line. A scalar applies uniformly; an array of shape (n_freqs,) supports dispersive media.

    Returns

    ndarray, shape (n_ports, n_ports, n_freqs) De-embedded S-parameter matrix.

    def deembed_thru(s_measured: numpy.ndarray, s_thru: numpy.ndarray) -> numpy.ndarray:

    De-embed a DUT using a thru-line measurement (TRL-lite).

    Assumes a symmetric two-port fixture: the thru standard captures the combined effect of both fixture halves in cascade. The fixture half-network is extracted from the thru, inverted, and removed from each side of the measured DUT.

    This is a simplified single-thru calibration; for full TRL accuracy, reflect and line standards would also be needed.

    The algorithm:

    1. Convert the thru S-matrix to a transfer (T) matrix.
    2. Take the matrix square root to get the fixture half T-matrix.
    3. Invert the fixture half.
    4. Cascade: T_dut = T_fix_inv @ T_meas @ T_fix_inv.
    5. Convert back to S-parameters.

    Parameters

    s_measured : ndarray, shape (2, 2, n_freqs) Measured S-parameters of the DUT including fixture. s_thru : ndarray, shape (2, 2, n_freqs) Measured S-parameters of the thru standard (fixture back-to-back).

    Returns

    ndarray, shape (2, 2, n_freqs) De-embedded S-parameters of the DUT.

    Raises

    ValueError If inputs are not 2-port matrices or have mismatched frequency counts.

    @dataclass(frozen=True)
    class PortValidationIssue:

    One validation diagnostic for an RF port observable.

    PortValidationIssue( code: str, message: str, severity: str = 'error', frequency_index: int | None = None, port_indices: tuple[int, ...] | None = None, value: float | None = None, limit: float | None = None)
    code: str
    message: str
    severity: str = 'error'
    frequency_index: int | None = None
    port_indices: tuple[int, ...] | None = None
    value: float | None = None
    limit: float | None = None
    @dataclass(frozen=True)
    class PortValidationReport:

    Validation result returned by validate_port_smatrix().

    PortValidationReport( source: str, n_ports: int = 0, n_freqs: int = 0, port_names: tuple[str, ...] = (), issues: tuple[PortValidationIssue, ...] = (), metrics: Mapping[str, float] = <factory>)
    source: str
    n_ports: int = 0
    n_freqs: int = 0
    port_names: tuple[str, ...] = ()
    issues: tuple[PortValidationIssue, ...] = ()
    metrics: Mapping[str, float]
    ok: bool

    Whether all error-severity checks passed.

    def by_code(self, code: str) -> tuple[PortValidationIssue, ...]:

    Return all issues with diagnostic code.

    def summary(self) -> str:

    Compact human-readable validation summary.

    def raise_for_failure(self) -> None:

    Raise AssertionError when the report contains an error.

    def validate_port_smatrix( obj: typing.Any | None = None, *, s_params: typing.Any | None = None, freqs: typing.Any | None = None, port_names: typing.Any | None = None, source: str | None = None, check_passivity: bool = True, passivity_limit: float = 1.0, passivity_tol: float = 1e-09, check_reciprocity: bool = False, reciprocity_atol: float = 1e-09, reciprocity_rtol: float = 1e-06, require_positive_freqs: bool = True, require_strictly_increasing_freqs: bool = True) -> PortValidationReport:

    Validate schema and selected invariants for an RF port observable.

    Thresholds are caller-controlled. Defaults are intentionally strict for synthetic/unit checks; integration tests may pass looser tolerances that match their validated physics envelope.

    def assert_port_smatrix_valid(*args: Any, **kwargs: Any) -> PortValidationReport:

    Validate and raise AssertionError on failure.

    Returns the report on success so tests can inspect metrics without running validation twice.

    def normalize_port_smatrix( obj: typing.Any | None = None, *, s_params: typing.Any | None = None, freqs: typing.Any | None = None, port_names: typing.Any | None = None, source: str | None = None) -> rfx.validation.PortSMatrixObservable:

    Normalize rfx S-matrix-like outputs into a canonical observable.

    The function uses duck typing so it works for:

    It raises ValueError for schema problems. Use validate_port_smatrix() when you want a non-raising report.

    def compare_replayed_smatrix( replayed: rfx.validation.PortSMatrixObservable | typing.Any, production: Any, *, atol: float = 1e-09, rtol: float = 1e-06) -> rfx.validation.PortReplayComparison:

    Compare an independent replay against production S-matrix output.

    def replay_smatrix_from_vi_dump( voltages: Any, currents: Any, *, freqs: typing.Any | None = None, port_impedances: Any = 50.0, port_names: typing.Any | None = None, driven_port_indices: typing.Any | None = None, current_convention: str = 'positive_into_dut', diagonal_frame: str = 'port_branch', reference_plane_offsets_m: typing.Any | None = None, propagation_constants: typing.Any | None = None, source: str = 'vi_dump_replay') -> rfx.validation.PortSMatrixObservable:

    Replay an S-matrix from raw V/I phasors.

    This is an independent post-processor for E3 evidence. It uses the power-wave split, role-selected per port to mirror the production lumped decomposer (issue #308):

    a = (V + Z0 I) / (2 sqrt(Z0)) (incident, at the DRIVEN port), b = (V - Z0 I) / (2 sqrt(Z0)) (reflected, at the DRIVEN port), and b_recv = -(V + Z0 I) / (2 sqrt(Z0)) (arriving, at a PASSIVE receive port — the production receive channel (V_fdtd - Z0 I) expressed in this dump's into-DUT voltage convention V = -V_fdtd; the overall sign is pinned empirically by the DC falsifier on the canonical thru, S21(DC) -> +1)

    diagonal_frame selects the DRIVEN diagonal. The default, "port_branch", is b/a on the stored (V, I) — the standard power-wave reflection for a pair that really is (voltage into the DUT, current into the DUT), and this function's contract. "driven_terminal" is (V_fdtd - Z0 I)/(V_fdtd + Z0 I) with V_fdtd = -V, for a dump whose stored voltage sign makes the pair inconsistent AT THE DRIVEN PORT while keeping the off-diagonal role channel right — which is the case for rfx's own lumped-port dump since 2026-09-21 (scripts/diagnostics/lumped_port_known_load_line.py; on that known-load line b/a at a driven port reads 4.757 where the closed form is 0.333, the reciprocal of the physical reflection). Such a dump says so in its metadata; see replay_smatrix_from_port_vi_dump(). The off-diagonal role channel is unchanged by this switch.

    In the "driven_terminal" frame the whole column shares ONE incident wave, the physical one at the driven port, so the diagonal and the off-diagonals have the same denominator. That is the wire family's whole-port frame, which rfx's lumped lane now evaluates at one live cell, and it needs no channel beyond the stored V and I.

    with current positive into the DUT by default. If the dump records current positive out of the DUT, set current_convention="positive_out_of_dut" and the sign is flipped before wave decomposition.

    Optional reference-plane shifts are explicit metadata, not hidden magic: reference_plane_offsets_m[i] > 0 means the reported reference plane is farther into the DUT than the raw measurement plane in port i's local coordinate. With propagation constant gamma this applies a_ref = a_raw exp(-gamma d) and b_ref = b_raw exp(+gamma d).

    @dataclass(frozen=True)
    class TouchstoneData:

    Metadata-aware Touchstone read result.

    The legacy read_touchstone() API intentionally returns only (s_params, freqs, z0). Use this dataclass through read_touchstone_full() when Touchstone 2.0 metadata such as per-port reference impedances or explicit matrix layout matters.

    TouchstoneData( s_params: numpy.ndarray, freqs: numpy.ndarray, z0: float | numpy.ndarray, reference: numpy.ndarray, version: str, parameter: str, fmt: str, freq_unit: str, layout: str, matrix_format: str, two_port_order: str, comments: tuple[str, ...] = (), information: tuple[str, ...] = ())
    s_params: numpy.ndarray
    freqs: numpy.ndarray
    z0: float | numpy.ndarray
    reference: numpy.ndarray
    version: str
    parameter: str
    fmt: str
    freq_unit: str
    layout: str
    matrix_format: str
    two_port_order: str
    comments: tuple[str, ...] = ()
    information: tuple[str, ...] = ()
    def read_touchstone( filepath: str | pathlib.Path, *, layout: str = 'auto') -> tuple[numpy.ndarray, numpy.ndarray, float]:

    Read S-parameters from a Touchstone file.

    This legacy compatibility API returns a scalar reference impedance. If a Touchstone 2.0 file carries non-uniform per-port references, use read_touchstone_full() instead.

    Parameters

    filepath : str or Path layout : str Auto-detect old unmarked rfx v1 exports; otherwise use standard order. An explicit layout overrides detection.

    Returns

    s_params : (n_ports, n_ports, n_freqs) complex array freqs : (n_freqs,) float in Hz z0 : float reference impedance

    def read_touchstone_full( filepath: str | pathlib.Path, *, layout: str = 'auto') -> TouchstoneData:

    Read S-parameters and Touchstone metadata.

    Supported subset:

    • S-parameters only
    • RI, MA, and DB numeric formats
    • Touchstone 1-style data blocks
    • Touchstone 2.0 metadata keywords used by rfx interop
    • Touchstone 2.0 information blocks preserved as raw lines
    • Full matrix data only

    layout="auto" detects legacy column-major order for unmarked rfx v1 exports with 3+ ports. Marked exports and external files use standard order. Two-port and v2 files are unaffected; explicit layouts override.

    def write_touchstone( filepath: str | pathlib.Path, s_params: numpy.ndarray, freqs: numpy.ndarray, z0: float = 50.0, freq_unit: str = 'GHz', fmt: str = 'RI', comments: list[str] | None = None, *, version: str = '1.0', layout: str | None = None, port_z0: numpy.ndarray | None = None, matrix_format: str = 'Full', two_port_order: str = '21_12', information: dict[str, object] | list[str] | tuple[str, ...] | None = None) -> None:

    Write S-parameters to a Touchstone file.

    Parameters

    filepath : str or Path Output file (extension determines port count: .s1p, .s2p, etc.) s_params : (n_ports, n_ports, n_freqs) complex array freqs : (n_freqs,) float in Hz z0 : reference impedance (ohm) freq_unit : "Hz", "kHz", "MHz", or "GHz" fmt : "RI" (real/imag), "MA" (mag/angle), or "DB" (dB/angle) comments : optional comment lines version : "1.0" or "2.0" "1.0" preserves the historical rfx writer shape. "2.0" writes Touchstone 2.0 metadata keywords for the supported S-parameter subset. layout : "legacy-rfx", "standard", or None Multi-port order. None means standard layout for both Touchstone 1.0 and 2.0 output. The legacy rfx layout is column-major for all port counts. The standard layout is row-major for 3+ ports. Two-port standard order is controlled by two_port_order. port_z0 : array-like or None Optional per-port reference impedances. Requires version="2.0" when non-uniform. matrix_format : str Touchstone 2.0 matrix format. Only "Full" is supported. two_port_order : "21_12" or "12_21" Explicit 2-port data order for standard Touchstone 2.0 files. information : dict, list, tuple, or None Optional Touchstone 2.0 information block. Dicts are written as sorted "key value" lines; sequences are written verbatim.

    def network_quality_metrics( s_params: numpy.ndarray, *, reciprocity_tol: float = 1e-12, passivity_tol: float = 1e-12) -> dict[str, object]:

    Return common RF network quality metrics for an S-matrix cube.

    The input must have shape (n_ports, n_ports, n_freqs). Metrics are host-side diagnostics for reports and interop gates; they do not alter any solver path.

    def save_optimization_result( path, result, metadata=None, *, include_latent=True, include_density=True, include_history=True):

    Save OptimizeResult or TopologyResult to HDF5.

    Parameters

    path : str or Path result : OptimizeResult or TopologyResult metadata : dict or None Extra metadata (grid shape, freq_max, etc.) stored as HDF5 attrs. include_latent : bool Save latent/density arrays (default True). include_density : bool Save projected density and PEC occupancy (default True). include_history : bool Save loss/beta history (default True).

    def load_optimization_result(path):

    Load optimization result from HDF5.

    Returns

    dict with keys: eps_design, loss_history, latent, density, etc.

    def save_far_field(path, ff_result, metadata=None):

    Save FarFieldResult to HDF5.

    Parameters

    path : str or Path ff_result : FarFieldResult metadata : dict or None

    def export_radiation_pattern(path, ff_result, freq_idx=0, *, input_power=None):

    Export radiation pattern as CSV for measurement comparison.

    Columns: theta_deg, phi_deg, E_theta_mag, E_theta_phase_deg, E_phi_mag, E_phi_phase_deg, gain_dBi

    Parameters

    path : str or Path ff_result : FarFieldResult freq_idx : int Frequency index to export. input_power : array-like (n_freqs,) or scalar, optional Input power in watts, passed to rfx.antenna.antenna_gain_dB() for realized gain. Without it, gain_dBi is absolute IEEE gain normalized by total radiated power. That integral requires full-sphere coverage, or one phi cut under an axisymmetric assumption. Realized gain with input_power accepts partial angular patterns.

    def export_geometry_json(path, sim):

    Export a lightweight geometry index as JSON (bounding boxes only).

    Intended for dataset indexing and eyeball review. Each geometry entry is recorded as its shape class name plus a bounding box, so a Cylinder and a Box with the same bounds are indistinguishable here, and materials are reduced to scalar eps_r/sigma/mu_r (dispersion poles are dropped). Sources are recorded as position + component only.

    This output therefore cannot round-trip and is not a design description: do not drive an external solver from it. For a round-trip-complete design document use rfx.interop.design_to_dict() / rfx.interop.design_to_json(), which record constructor parameters and refuse rather than degrade what they cannot represent.

    Parameters

    path : str or Path sim : Simulation

    def save_experiment_report(path, sim, result, extra=None):

    Save standardized experiment metadata as JSON.

    Auto-collects: sim config, grid shape, timing, loss history.

    Parameters

    path : str or Path sim : Simulation result : Result, OptimizeResult, or TopologyResult extra : dict or None Additional metadata (GPU info, notes, etc.)

    def save_simulation_dataset( path, sim, result, *, include_fields=False, include_time_series=True, include_s_params=True, include_config=True):

    Save simulation input-output pair for surrogate model training.

    Creates an HDF5 file with selectable sections:

    • Input: simulation config and grid metadata
    • Output: time series, S-parameters
    • Fields: final 3D E/H snapshot (large, opt-in)

    Parameters

    path : str or Path sim : Simulation result : Result include_fields : bool Save full 3D E/H field arrays (default False — large). include_time_series : bool Save probe time series (default True). include_s_params : bool Save S-parameters and frequencies (default True). include_config : bool Save simulation config as input metadata (default True).

    @dataclass(frozen=True)
    class ArtifactBundle:

    Paths written by export_artifact_bundle().

    ArtifactBundle( root: pathlib.Path, report_json: pathlib.Path, manifest_json: pathlib.Path, report_markdown: pathlib.Path | None = None, scene_json: pathlib.Path | None = None, geometry_json: pathlib.Path | None = None, field_vtk: pathlib.Path | None = None, files: tuple[pathlib.Path, ...] = ())
    root: pathlib.Path
    report_json: pathlib.Path
    manifest_json: pathlib.Path
    report_markdown: pathlib.Path | None = None
    scene_json: pathlib.Path | None = None
    geometry_json: pathlib.Path | None = None
    field_vtk: pathlib.Path | None = None
    files: tuple[pathlib.Path, ...] = ()
    def export_artifact_bundle( path: str | pathlib.Path, sim: Any, result: Any = None, *, include_markdown: bool = True, include_scene: bool = True, include_geometry_json: bool = True, include_field_vtk: bool = False, field_state: Any = None, field_grid: Any = None, extra: dict[str, typing.Any] | None = None) -> ArtifactBundle:

    Write a runtime artifact bundle directory and return its paths.

    def save_state( path: str | pathlib.Path, state: rfx.core.yee.FDTDState, grid=None) -> None:

    Save FDTD state to an HDF5 file.

    Parameters

    path : str or Path Output file path (.h5). state : FDTDState Field state to save. grid : Grid or None If provided, grid metadata is stored as attributes.

    def load_state(path: str | pathlib.Path) -> tuple[rfx.core.yee.FDTDState, dict]:

    Load FDTD state from an HDF5 file.

    Returns

    state : FDTDState metadata : dict Grid metadata if it was saved, otherwise empty dict.

    def plot_field_slice( state, grid, *, component: str = 'ez', axis: str = 'z', index: int | None = None, title: str | None = None, cmap: str = 'RdBu_r', vmax: float | None = None) -> object:

    Plot a 2D slice of a field component.

    Parameters

    state : FDTDState grid : Grid component : field name ("ex", "ey", "ez", "hx", "hy", "hz") axis : normal axis for the slice ("x", "y", or "z") index : grid index along axis (default: center) title : plot title cmap : colormap vmax : symmetric color range [-vmax, vmax]

    Returns

    matplotlib Figure

    def plot_s_params( s_params: numpy.ndarray, freqs: numpy.ndarray, *, db: bool = True, title: str = 'S-Parameters') -> object:

    Plot S-parameter magnitudes vs frequency.

    Parameters

    s_params : (n_ports, n_ports, n_freqs) complex freqs : (n_freqs,) Hz db : plot in dB title : plot title

    Returns

    matplotlib Figure

    def plot_radiation_pattern( ff, *, freq_idx: int = 0, phi_idx: int = 0, db: bool = True, title: str | None = None) -> object:

    Plot radiation pattern in polar coordinates.

    Parameters

    ff : FarFieldResult freq_idx : frequency index phi_idx : phi cut index db : plot in dB (normalized) title : plot title

    Returns

    matplotlib Figure

    def plot_time_series( time_series: numpy.ndarray, dt: float, *, labels: list[str] | None = None, title: str = 'Probe Time Series') -> object:

    Plot probe time series.

    Parameters

    time_series : (n_steps, n_probes) array dt : timestep in seconds labels : probe labels title : plot title

    Returns

    matplotlib Figure

    def plot_rcs( rcs_result, *, freq_idx: int = 0, phi_idx: int = 0, polar: bool = True, title: str | None = None) -> object:

    Plot RCS pattern in polar or rectangular coordinates.

    Parameters

    rcs_result : RCSResult Output from compute_rcs(). freq_idx : int Frequency index to plot. phi_idx : int Phi cut index to plot. polar : bool If True, plot in polar coordinates. If False, rectangular. title : str or None Plot title.

    Returns

    matplotlib Figure

    def plot_smith( s11: numpy.ndarray, freqs: numpy.ndarray, *, z0: float = 50.0, ax=None, show_vswr: bool = True, markers: list[float] | None = None, title: str | None = None):

    Plot S11 data on a Smith chart.

    Parameters

    s11 : (n_freqs,) complex Reflection coefficient (Gamma) values. freqs : (n_freqs,) Hz Corresponding frequencies. z0 : float Reference impedance (used only for annotation, not for re-normalisation). ax : matplotlib Axes, optional Axes to plot on. If None, a new figure is created. show_vswr : bool If True, draw VSWR = 2 and VSWR = 3 circles. markers : list of float, optional Frequencies (in Hz) at which to place labelled markers. title : str, optional Plot title. Defaults to "Smith Chart".

    Returns

    matplotlib Axes

    def plot_geometry_3d( sim, *, show_domain: bool = True, show_ports: bool = True, title: str | None = None, figsize: tuple[float, float] = (10, 8), scale: float = 1000.0, scale_label: str = 'mm', backend: str = 'auto') -> object:

    Render simulation geometry in 3D.

    Parameters

    sim : Simulation rfx Simulation object with geometry and ports defined. show_domain : bool Show the computational domain wireframe. show_ports : bool Show port locations as arrows. title : str or None Plot title. figsize : tuple Figure size. scale : float Coordinate scale factor (default 1e3 for mm). scale_label : str Unit label for axes. backend : str "auto" (pyvista if available, else matplotlib), "matplotlib", or "pyvista".

    Returns

    Figure (matplotlib) or Plotter (pyvista)

    def plot_field_3d( state, grid, *, component: str = 'ez', threshold: float = 0.1, title: str | None = None, figsize: tuple[float, float] = (10, 8), cmap: str = 'RdBu_r', scale: float = 1000.0, scale_label: str = 'mm', backend: str = 'auto') -> object:

    3D field visualization with isosurface or volume rendering.

    Parameters

    state : FDTDState grid : Grid component : field component name threshold : float Isosurface threshold as fraction of peak value (0-1). title : str or None figsize : tuple cmap : colormap name scale : coordinate scale factor scale_label : unit label backend : "auto", "matplotlib", or "pyvista"

    Returns

    Figure (matplotlib) or Plotter (pyvista)

    def save_field_vtk( state, grid, filename: str, *, components: tuple[str, ...] = ('ex', 'ey', 'ez', 'hx', 'hy', 'hz'), scale: float = 1000.0) -> str:

    Export field data to VTK format for ParaView.

    Parameters

    state : FDTDState grid : Grid filename : str Output filename (without extension). components : tuple of str Field components to export. scale : float Coordinate scale factor.

    Returns

    str : path to the written file

    def save_field_animation( result, filename: str, *, component: str = 'ez', slice_axis: str = 'z', slice_index: int | None = None, fps: int = 15, colormap: str = 'RdBu_r', vmin: float | None = None, vmax: float | None = None, dpi: int = 100, figsize: tuple = (6, 5), interval: int = 1):

    Save field animation from simulation snapshots.

    Parameters

    result : rfx.Result or dict Simulation result with .snapshots attribute, or a raw snapshots dict mapping component names to arrays of shape (n_frames, nx, ny, nz) or (n_frames, nx, ny). filename : str Output file path. Extension determines format: .gif uses PillowWriter, .mp4 uses FFMpegWriter (falls back to GIF if ffmpeg is unavailable). If no recognised extension, .gif is appended. component : str Field component to animate (default "ez"). slice_axis : str Axis normal to the slice plane ("x", "y", or "z"). Only used when snapshot data is 3D per frame. slice_index : int or None Index along slice_axis. None selects the centre. fps : int Frames per second. colormap : str Matplotlib colormap name. vmin, vmax : float or None Colour-scale limits. None computes symmetric auto-range from the data. dpi : int Resolution in dots per inch. figsize : tuple Figure size in inches (width, height). interval : int Frame stride — use every interval-th snapshot frame. Default 1 (every frame). This is a stride over the RECORDED frames, applied after the run, not SnapshotSpec.interval (which is in time steps): a run recorded every 10 steps animated with interval=3 shows every 30th step.

    Returns

    str Path to the saved animation file.

    Raises

    ValueError If result has no snapshots or the requested component is missing. ImportError If matplotlib or Pillow are not installed.

    def device_info() -> rfx.gpu.DeviceInfo:

    Report available JAX compute devices.

    def benchmark( grid_sizes: list[tuple[int, int, int]] | None = None, n_steps: int = 100) -> list[rfx.gpu.BenchResult]:

    Benchmark FDTD throughput across grid sizes.

    Returns a list of BenchResult sorted by grid size.

    def export_training_data( sweep_result, *, output_path: str | pathlib.Path, format: str = 'npz') -> pathlib.Path:

    Export parametric sweep results as neural network training data.

    From a SweepResult or VmapSweepResult, creates arrays:

    • inputs : parameter values, shape (n_samples, 1)
    • outputs : time-series probe data, shape (n_samples, n_steps, n_probes) (SweepResult) or (n_samples, n_steps, n_probes) (VmapSweepResult). When S-parameters are available (SweepResult with ports), an s_params array is also stored.
    • metadata : parameter name and frequency array (when available).

    Parameters

    sweep_result : SweepResult or VmapSweepResult Output of parametric_sweep or vmap_material_sweep. output_path : str or Path Destination file path. format : "npz" Output format (currently only NumPy .npz is supported).

    Returns

    Path The written file path.

    Raises

    ValueError If format is unsupported or sweep_result type is unrecognised.

    def export_geometry_sdf(sim, *, resolution: float = 0.001) -> numpy.ndarray:

    Export simulation geometry as a signed distance field.

    Builds a 3-D grid at the requested resolution covering the simulation domain and evaluates the geometry mask. The SDF is approximated from the binary mask via a distance transform:

    • negative inside geometry (material regions)
    • positive outside
    • magnitude = approximate Euclidean distance to the nearest boundary (in metres)

    This is suitable as input to geometry-conditioned neural operators (e.g., DeepONet, Fourier Neural Operator).

    Sheets. A conductor declared as a sheet — add_thin_conductor, or a zero-thickness PEC Box, which is the same declaration (lattice ownership contract §1.5) — has no interior, so a containment test finds it only if its plane happens to land exactly on a sample. Before this it did not land: add_thin_conductor sheets are not in sim._geometry at all and never reached the exporter, so every sheet-declared ground plane, patch and trace was missing from the exported training data with no error anywhere.

    A zero-thickness region cannot be represented in a sampled occupancy field as zero thickness. The convention here, stated rather than inferred: a sheet occupies exactly ONE sample layer — the sample plane nearest its declared mid-plane, with the drawn footprint sampled closed in-plane — so the distance transform reads -resolution on the sheet and the sheet's apparent thickness in the SDF is one sample, not zero. That is the same realization rule the solve uses (a sheet is one node plane); only the lattice differs. A sheet whose plane or footprint cannot be placed on this lattice raises rather than vanishing — refine resolution.

    Parameters

    sim : Simulation A configured simulation with geometry added. resolution : float Spatial resolution of the SDF grid (metres). Default 1 mm.

    Returns

    sdf : ndarray, shape (Nx, Ny, Nz) Signed distance field in metres.