Skip to content

Rigorous Coupled-Wave Analysis (RCWA)

jno.rcwa is an optional solver for periodic, layered electromagnetic structures — the canonical case being an extruded metasurface unit cell (a patterned dielectric slab between two semi-infinite ambients). Unlike jno.fem, which discretises the whole volume, RCWA is semi-analytic in the propagation direction: each layer is expanded in a truncated in-plane Fourier basis, solved by an eigenmode decomposition, and the layers are stitched with a scattering matrix. For a periodic slab that is far cheaper than a full 3-D complex-Helmholtz solve.

It is built on fmmax, a differentiable JAX Fourier Modal Method, imported lazily — the core jno install does not pull it in. Enable the backend with the rcwa extra:

pip install jax-numerical-operators[rcwa]      # or:  pixi run -e rcwa ...

The front door — infer the problem from a jNO constraint list

Hand jno.rcwa the same constraint list you would give jno.fem (or an already-built FEM). Nothing else is required — it reads everything out of the traced problem:

rc  = jno.rcwa(constraints, orders=300)
sol = rc.solve()                 # period, layers, permittivity, wavelength, incidence all inferred
sol.efficiency("T")              # transmitted power fraction
sol.order(+1, 0)                 # a specific diffraction order

rc.spec                          # the inferred RcwaSpec — inspectable WITHOUT fmmax

What is inferred from the problem, and from where:

inferred source in the list / domain
periodicity + period (Px,Py) the Floquet ties u(left)-u(right), u(front)-u(back) — absent ⇒ raise, never assumed
super/substrate ambients the two z-normal radiation faces
permittivity the K0²·ε coefficient recovered from the scalar Helmholtz volume term, sampled along z, then grouped by detect_layers
wavelength / k0 the coefficient's value in the vacuum superstrate (k0 = √coeff); pass wavelength= to override
incident wave (lit face + angle k_in) the assembled forcing b — a constant-phase source ⇒ normal incidence
incident polarization a vector list's incident field E_inc; for a scalar list, the polarization the scalar equation describes — see Polarization
tensor permittivity ε̂ an inner(ε̂ @ u, v) mass term (a 3×3 MatrixView) — birefringence, polarization conversion
in-plane PML a complex coordinate stretch S = 1 + iσ/k in the stiffness coefficients — see below
internal source a - f·v / - inner(J, v) volume forcing — a dipole / Gaussian emitter — see below

The permittivity is recovered by splitting the volume weak form ∇u·∇v − K0²·ε·u·v: the stiffness summands carry trial/test inside Jacobian nodes, the mass summand carries them as bare values, so dropping the u·v factor leaves K0²·ε. Because it is the same traced coefficient, its dependence on a trainable jno.np.parameter carries through, so inverse design flows unchanged. Only orders (a numerical truncation choice) is genuinely the user's; wavelength is an optional override for the case where no ambient is vacuum.

Because RCWA solves the infinitely periodic problem, a finite aperture with plain absorbing side walls and no ties is rejected rather than silently periodicised. To model an isolated scatterer, keep the ties and add an in-plane PML frame (below) — the standard periodic-supercell trick.

Polarization

RCWA solves Maxwell's equations, so every solve has an incident polarization. jno.rcwa takes it from the problem, logs it, and keeps it on rc.spec.polarization (a transverse Jones vector (p_x, p_y)) with the reason on rc.spec.polarization_from:

  • A vector (curl-curl) list states it: the transverse part of its incident field E_inc.
  • A scalar Helmholtz list is solved in the polarization for which Maxwell reduces exactly to the written equation Δu + k0²ε u = 0: the field along an in-plane axis every layer is invariant along, when the incidence has no component along it — TE, E parallel to the lines of a 1-D grating (either orientation), and s-polarized light on a uniform stack at any azimuth. jno.rcwa and jno.fem then answer the same equation. For a lamellar grating (period 0.6λ, ε = 11, fill 0.5, 0.05λ thick) this is a reflectance of 0.617 — the TE value of an independent RCWA — where the x-polarized solve used for every list before gave the TM value, 0.107.
  • A stack patterned along both axes (pillars, holes), an anisotropic or PML stack, or a 1-D grating lit off its invariant axis (conical mount) reduces to the scalar equation for no polarization. It is solved x-polarized and the log says so; choose with polarization= or write the vector list.

polarization= overrides the inference: "x", "y", or any Jones vector — (1, 1j) is circular light, which a 1-D grating in the classical mount reflects at the mean of its TE and TM reflectances. sol.jones() gives the full 2×2 response regardless. The scalar aerial and bulk images use the field component along the incident polarization.

In-plane PML — an isolated scatterer from a periodic supercell

A Perfectly Matched Layer is a complex coordinate stretch S = 1 + iσ/k (σ ramps up in an absorbing frame, 0 in the physical core). Written into the scalar Helmholtz volume term it appears as anisotropic stiffness coefficients (and a matching mass coefficient):

Sx, Sy = 1 + 1j*sx/K0, 1 + 1j*sy/K0                       # sx, sy ramp near the x / y walls
vol = (  (Sy/Sx)*(ui.x*vi.x) + (Sx/Sy)*(ui.y*vi.y) + (Sx*Sy)*(ui.z*vi.z)   # uniaxial stretch, Sz = 1
       - K0**2 * (Sx*Sy) * eps * (u*vi) )
constraints = [vol, absorbing_top, absorbing_incident_bottom, u_left-u_right, u_front-u_back]
sol = jno.rcwa(constraints, orders=200).solve()

The front door reads the stretch Λ = diag(Sy/Sx, Sx/Sy, Sx·Sy) straight off the stiffness coefficients and forms the Maxwell uniaxial PML — a diagonal ε̂ and μ̂ (ε̂ = ε·Λ, μ̂ = Λ) — solved with fmmax's general anisotropic eigensolve. The Floquet ties stay (fmmax is inherently periodic); the absorbing frame just makes the supercell walls non-coupling, so light diffracted toward a neighbour is absorbed instead of recirculated, and the cell behaves like a single isolated scatterer. The traced stretch is honoured exactly — any σ profile, not a fixed built-in one.

A design jno.np.parameter on the scatterer inside the PML supercell is differentiable — the PML layers are re-derived from it (ε̂ = ε·Λ, μ̂ = Λ), so jax.grad flows and you can inverse-design an isolated structure. Scope: a uniaxial (diagonal) in-plane stretch on the scalar Helmholtz term. An off-diagonal stretch or a z-stretch (meaningless for RCWA — the S-matrix already gives outgoing-wave z-boundaries) each raise.

Internal sources — dipole / Gaussian emitters

An emitter is authored the same way you drive jno.fem or a PINN — as a forcing term in the residual, not a special object. A scalar monopole is - f·v; a vector dipole is - inner(J, v) (with J a current-density vector giving the orientation):

profile = jno.np.exp(-(((xi-x0)**2 + (yi-y0)**2 + (zi-z0)**2) / (2*w**2)))   # localized emitter
vol = ui.x*vi.x + ui.y*vi.y + ui.z*vi.z - K0**2*eps*(u*vi) - A*profile*vi     # `- A·profile·v` forcing
sol = jno.rcwa([vol, absorbing_top, absorbing_bottom, u_left-u_right, u_front-u_back]).solve()
sol.power("up"), sol.power("down")     # power radiated into each ambient
sol.extraction("up")                   # directionality = up / (up + down)

The front door detects the trial-free / test-present volume summand, localizes it (centroid → point dirac_delta_source vs Gaussian gaussian_source(fwhm); which z-layer it sits in), splits the stack at the source plane, and drives fmmax's amplitudes_for_source. A higher-index substrate correctly biases emission downward (substrate-emission enhancement — the LED/OLED extraction physics). The amplitude, orientation, lateral location, z-position and Gaussian width — plus the design ε — are all differentiable (jax.grad of extraction or emitted power flows through), so you can inverse-design the environment around an emitter, optimize where it sits, or recover an unknown source strength. Only the discrete choices (which layer, point-vs-Gaussian) are frozen at construction.

Scope: scalar Helmholtz, the source must lie in a finite (contrast-defined) layer, k_in is nudged off the singular Γ-point (a single Bloch point — full Brillouin-zone averaging is future work), and a boundary plane-wave incidence together with an internal source raises (author one excitation). Purcell / LDOS (total emitted power ÷ a homogeneous-medium reference) is not wired yet.

Subpixel smoothing (for inverse design)

smoothing=k (default 1 = off) supersamples each RCWA pixel k×k and area-averages the permittivity, anti-aliasing material boundaries:

jno.rcwa(constraints, orders=200, smoothing=3)     # 3×3 supersample per pixel, averaged

Why it matters for inverse design: a point-sampled ε staircases as a design boundary sweeps — the rasterized structure is piecewise-constant and only jumps when an edge crosses a grid line, so the gradient w.r.t. a boundary-moving parameter is jumpy (flat, then a spike). Area-averaging makes the rasterized fill — and hence the gradient — vary smoothly. It costs k²× more (cheap) coefficient evaluations; the fmmax eigensolve size is unchanged. Fully differentiable (a plain mean), threaded through every ε path (scalar, tensor, PML, nodal density) in both the eager and the re-sampled (parametric) solve.

This is the arithmetic (isotropic) form of subpixel averaging — a solid, general anti-aliasing. The rigorous tensor scheme (Farjadpour et al., Opt. Lett. 31, 2972 (2006): arithmetic tangential + harmonic normal averaging at the interface, which additionally accelerates Fourier convergence) is future work. Recommended k = 2–4 for topology optimization; leave it off for a fixed-geometry forward solve.

Profiling and convergence sweeps — solve(orders=…, profile=…)

Two escape hatches on .solve() for a forward solve (they don't affect the differentiable no-arg path):

rc = jno.rcwa(constraints, orders=200)

# re-solve at a different Fourier truncation (a fresh engine; the construction `orders` is untouched)
T20  = rc.solve(orders=120).efficiency("T")
T30  = rc.solve(orders=180).efficiency("T")     # converged if T20 ~= T30 (a Richardson check)

# JAX performance profile, exactly like jno.core.solve(profile=True)
sol = rc.solve(profile=True)
# -> [rcwa profile] 3 layers · n_t=193 · 386×386 eigenproblem/layer | solve 1.2 s | Perfetto trace -> ./rcwa_traces

orders=N is the enabler for a convergence sweep: RCWA is only exact as the truncation → ∞, and how fast it converges is structure-dependent (a sharp, high-contrast pillar can need several ×N more orders than a smooth slab), so comparing efficiency at N vs ~1.5N tells you whether orders is enough — solve at both and check they agree.

profile=True runs the solve eagerly (at the current parameter values) inside a jax.profiler.trace with per-stage TraceAnnotations (rcwa:eigensolve, rcwa:s_matrix), prints the problem size and wall time, and writes a Perfetto trace to ./rcwa_traces. RCWA's cost is dominated by the O((2·n_t)³) per-layer eigensolves — the trace makes that explicit (open it at chrome://tracing or Perfetto UI).

Aerial imaging — sol.aerial(...) (computational lithography)

RCWA gives the rigorous mask diffraction; sol.aerial(NA, source, …) is the litho imaging step on top of it — the partially-coherent aerial image (wafer-plane intensity) by Abbe source integration:

sol = jno.rcwa(mask_constraints, orders=200).solve()   # rigorous mask (mask-3D, polarization)
img = sol.aerial(NA=0.33, source=0.5)                  # -> (grid, grid) intensity over one period

The mask's diffraction orders (this solution's spectrum) are projected through a lens of numerical aperture NA and summed over the illumination source. Everything else — wavelength, period, the complex mask spectrum — is read from the solve; only the optics are yours. source is polymorphic: a float σ (conventional, partial-coherence radius), a (σ_in, σ_out) tuple (annular), or a raw array of pupil weights (differentiable, for source-mask optimization). defocus= applies the quadratic pupil phase; kind="R" handles a reflective EUV mask.

Because the whole chain is JAX, the image is differentiable in the mask design and the source — so jax.grad of a printed-vs-target loss drives OPC / ILT / SMO, back through the imaging and the RCWA mask solve:

loss = lambda: ((jno.rcwa(mask(rho)).solve().aerial(NA=0.33, source=src) - target) ** 2).mean()

By default the image is scalar (the field component along the incident polarization — E_x for an x-polarized solve) — correct at low NA. Pass polarization= to switch on the vector high-NA model, which rotates each order's transverse (E_x, E_y) to the 3-D wafer field through the Richards-Wolf/Flagello vector pupil (with the aplanatic 1/√(cosθ) apodization), so the TM component loses contrast at large ray angles — the defining high-NA effect:

img = sol.aerial(NA=0.9, source=0.5, polarization="x")            # linearly polarized (TM for an x-grating)
img = sol.aerial(NA=0.9, source=0.5, polarization="unpolarized")  # mean of the two linear images

"x"/"y" are linear illumination; "unpolarized" averages the two. At NA→0 the vector pupil is the identity and the image reduces to the scalar one. Validated against the imaging limits (open frame → uniform; partial coherence reduces contrast), the vector→scalar reduction as NA drops, the TE-over-TM contrast split at high NA, and by jax.grad vs finite difference. The vector pupil follows Flagello, Milster & Rosenbluth, J. Opt. Soc. Am. A 13, 53 (1996). The full angular-rigorous Abbe (re-solving the mask per source point) remains future work.

Resist — sol.expose(...).develop(resist) (the developed pattern)

The last step of the computational-lithography chain mask → aerial image → resist is development. sol.expose(...) returns the optical exposure at the wafer (optics only), and a resist model turns it into the developed pattern:

exp = sol.expose(NA=0.33, source=0.5)                       # the exposure (all aerial(...) args apply)
img = exp.develop(jno.litho.Threshold(threshold=0.3, diffusion=0.02, steepness=50))  # -> (grid, grid) in [0, 1]

A resist is any callable exposure -> developed field, so new physics plugs into the same seam without touching the imaging code. The shipped jno.litho.Threshold is the fast, differentiable design-loop model: it reads the exposure's aerial image (exp.intensity()), optionally blurs it by a linear post-exposure-bake diffusion (a periodic Gaussian of length diffusion), then develops by a soft constant threshold, sigmoid(steepness · (I − threshold)) — 1 = clears. threshold sets the printed CD, steepness the development contrast. sol.printed(NA, source, …, resist=…) is a one-call shortcut for expose(…).develop(resist), defaulting resist to Threshold().

Because the whole chain stays JAX, jax.grad of a printed-vs-target loss drives OPC / ILT / SMO with the resist in the loop — back through development, imaging, and the rigorous RCWA mask solve:

r = jno.litho.Threshold(threshold=0.3)
loss = lambda: ((jno.rcwa(mask(rho)).solve().printed(NA=0.33, source=src, resist=r) - target) ** 2).mean()

Threshold is the standard linear-diffusion + constant-threshold resist (Poonawala & Milanfar, IEEE Trans. Image Process. 16, 774, 2007; PEB diffusion after Mack, Fundamental Principles of Optical Lithography, 2007).

For a physical resist, jno.litho.CAResist plugs into the same develop(...) seam — the rigorous 3-species reaction-diffusion post-exposure bake (dos Santos), authored as one transient jno.fem system:

peb = jno.litho.CAResist(n=64, t_peb=45.0, steps=30, dill_c=1.0, dose=1.0, diffusion_length=(12.0, 8.0))
dev = sol.expose(NA=0.33, source=0.5).develop(peb)   # -> developed (n, n) pattern in [0, 1]

It maps the exposure's aerial intensity to a latent acid via Dill kinetics (A(0) = 1 − exp(−dill_c·dose·I)), then bakes inhibitor M, acid A, and quencher B:

M_t = −k1 M A − k2 M                A_t = D_A ΔA − k3 A − k4 A B                B_t = D_B ΔB − k4 A B − k5 B

on a doubly-periodic film mesh, and returns 1 − M (the developed/soluble fraction, positive tone). It is heavier than Threshold (a nonlinear multifield transient solve) — for verification, not the fast design loop. The whole chain stays differentiable: jax.grad of a developed-pattern loss w.r.t. the mask matches finite difference through the RCWA solve → aerial image → Dill acid → transient PEB (so you can design the mask against the physically-baked pattern, not just the aerial image).

Standing-wave bulk image. The exposure also exposes the depth-resolved field inside the resist film via exp.bulk(film) — each diffraction order is refracted into the film and interferes with its substrate reflection (the vertical standing wave), with absorption from a complex resist index:

vol = sol.expose(NA=0.33, source=0.5).bulk(jno.litho.Film(n_resist=1.7 + 0.02j, thickness=0.1, n_substrate=4.0, nz=16))
# -> (grid, grid, nz) intensity |E(x, y, z)|²

jno.litho.Film carries the resist stack (n_resist, thickness, n_substrate, n_top, nz). At z = 0 with no substrate reflection bulk equals the aerial image; a reflective substrate produces a vertical standing wave of period λ/(2·n_resist). bulk is scalar (the co-polarized field) with a single-substrate-reflection model (full multilayer Airy and a vector bulk image are future work).

3-D PEB. Pass a Film to CAResist and it switches from the 2-D aerial-driven bake to a full 3-D (x, y, z) reaction-diffusion PEB: the acid is seeded from the standing-wave bulk image, the species diffuse in x, y and z on a jno.shape box (periodic in x, y via a conforming remesh; free in z), and a developed (n, n, film.nz) volume is returned:

film = jno.litho.Film(n_resist=1.6, thickness=0.1, n_substrate=4.0, nz=16)
vol  = sol.expose(NA=0.33, source=0.5).develop(jno.litho.CAResist(film=film, n=64, t_peb=45, steps=30))

The film mesh is isotropic tets (size = smallest box dimension / 4), so very thin films are expensive (anisotropic meshing is future work). Without film, CAResist stays the 2-D model.

The backend — explicit layers

jno.Rcwa takes a hand-built stack directly; this is what the front door constructs internally:

import numpy as np
inf = np.inf
rc  = jno.Rcwa([(inf, 1.0), (0.4, 4.0), (inf, 1.0)],   # [(thickness, eps), ...] super -> substrate
               period=(0.6, 0.6), orders=200, wavelength=1.03, assume_periodic=True)
sol = rc.solve()

Layer detection

jno.rcwa.detect_layers(E, z) groups a z-sampled permittivity E of shape (Nz, Ng, Ng) into RCWA layers, with the two ambients marked semi-infinite. Continuous z-variation (an inclined or curved geometry) raises unless you opt in with slices=N to staircase it.

A layer's thickness is the distance between the interfaces that bound it. For an analytic permittivity (a jno.fn of the coordinates) the front door finds each interface by bisecting between the two z-samples either side of it, so a thickness is exact to machine precision whatever nz is. RCWA's answer is a Fabry–Pérot sum over the layer thicknesses, so this matters: measuring between the first and last samples inside a layer, as earlier versions did, took a 0.2 slab sampled every 1/63 as 0.175 thick, and its reflectance as 0.34 against Airy's 0.62. A permittivity given as a mesh field (a per-node design parameter) is bisected on its piecewise-linear interpolant instead, so each interface sits where the field crosses halfway between the two layers — the geometry the mesh represents, and no sharper than the mesh: a slab that ends between two node layers is located to within their spacing.

Never fails silently

Every inference is validated and raises RcwaError (or ImportError for the missing backend) with a concrete fix:

condition guard
fmmax not installed ImportError pointing at the [rcwa] extra
no Floquet ties (finite aperture) raises — author periodic side walls
periodicity in only one of x / y raises — add the missing tie
a scalar list on a stack patterned along both axes logged — solved x-polarized; choose with polarization=
a vector incident field with no transverse part raises — it carries no power into the stack
a layer interface from a mesh-field permittivity logged — located on the field's interpolant, as sharp as the mesh
no z-normal ambient faces found raises — tag the top/bottom faces
eps varies continuously in z raises unless slices=N
no source / forcing in the problem raises — nothing to illuminate with
forcing spread over a z-range, not one face raises — put the incident wave on one ambient
patterned grid under-resolves orders raises with the required grid size (Nyquist)
energy T + R > 1 after a solve raises — raise orders

Two fmmax conventions are baked in so callers never rediscover them: the Poynting flux is summed with sign (an abs() sum over-counts and can report T+R>1), and the JONES_DIRECT_FOURIER factorization is the default (the naive rule converges poorly for high-contrast dielectrics such as a-Si). Correctness is checked against the analytic Fresnel/transfer-matrix transmittance in the tests.

Roadmap

  • an as_precond path so RCWA can serve as the layered-background preconditioner M⁻¹ = A₀⁻¹ for the large 3-D FEM complex-Helmholtz solve, once the FEM↔grid transfer is wired;
  • a differentiable PML path (a design jno.np.parameter routed through a PML supercell — the forward solve works today, the re-sample does not yet).

References

  • M. G. Moharam & T. K. Gaylord, Rigorous coupled-wave analysis of planar-grating diffraction, J. Opt. Soc. Am. 71, 811 (1981).
  • M. G. Moharam, D. A. Pommet, E. B. Grann & T. K. Gaylord, Stable implementation of the enhanced transmittance matrix approach, J. Opt. Soc. Am. A 12, 1077 (1995).
  • W. C. Chew & W. H. Weedon, A 3D perfectly matched medium from modified Maxwell's equations with stretched coordinates, Microw. Opt. Technol. Lett. 7, 599 (1994) — the complex coordinate stretch.
  • Z. S. Sacks, D. M. Kingsland, R. Lee & J.-F. Lee, A perfectly matched anisotropic absorber for use as an absorbing boundary condition, IEEE Trans. Antennas Propag. 43, 1460 (1995) — the uniaxial ε̂/μ̂ PML.
  • A. Farjadpour et al., Improving accuracy by subpixel smoothing in the finite-difference time domain, Opt. Lett. 31, 2972 (2006) — the rigorous tensor subpixel-smoothing scheme (jno.rcwa currently does the arithmetic form).

API

jno.rcwa.rcwa

rcwa(problem, *, orders, wavelength=None, grid=64, nz=64, slices=None, params=None, formulation='JONES_DIRECT_FOURIER', smoothing=1, polarization=None)

Infer and build an RCWA problem from a jNO constraint list (or built FEM).

Everything is read out of the traced problem: periodicity + period (Floquet ties — absent ⇒ raise), the super/substrate ambients, the permittivity (the K0**2*eps coefficient recovered from the scalar Helmholtz volume term, sampled along z; a tensor ε̂ from inner(ε̂ @ u, v)), the wavelength (k0 from the vacuum superstrate, unless wavelength is given), and the incident wave (illuminated face + transverse angle k_in, from the assembled forcing). An in-plane PML (a complex coordinate stretch in the stiffness coefficients) is detected and honoured as a diagonal Maxwell ε̂/μ̂ — the supercell then behaves as an isolated scatterer, and a design parameter on the scatterer ε is differentiable (inverse design of an isolated structure). Scope: uniaxial (diagonal) in-plane stretch only, scalar Helmholtz.

An internal source (a - f·v / - inner(J, v) volume forcing) switches the solve to an emission problem: the source is localized (point vs Gaussian; dipole orientation from J), the stack is split at the source plane, and sol.power("up"|"down"|"total") / sol.extraction("up"|"down") give the radiated power and directionality. The amplitude, orientation, lateral location, z-position and Gaussian width, plus the design ε, are all differentiable (only the discrete layer choice is frozen); k_in is nudged off the singular Γ-point, and the source must sit in a finite (contrast-defined) layer. Scalar Helmholtz only for now; a boundary incidence + internal source together raise.

Parameters

problem: The same constraint list you would pass to :func:jno.fem, or an already-built FEM. orders: Fourier truncation for the modal solve. wavelength: Free-space wavelength (same length unit as the geometry). Optional — inferred from the superstrate (assumed vacuum) when omitted. Pass it to override or if no ambient is vacuum. grid, nz: Transverse resolution and number of z-samples used to detect the layer stack. slices: Staircase a continuously-varying permittivity into this many layers instead of raising. smoothing: Subpixel-smoothing factor (default 1 = off). smoothing=k supersamples each RCWA pixel k×k and area-averages the permittivity, anti-aliasing material boundaries. Recommended (2–4) for inverse design: it cuts Fourier (Gibbs) ringing so fewer orders converge, and makes the gradient w.r.t. a boundary-moving design parameter smooth rather than staircased. Costs k²× more coefficient evaluations (cheap) — the fmmax eigensolve size is unchanged. polarization: The incident polarization: "x", "y", or a transverse Jones vector (p_x, p_y) (complex entries give elliptical light). Usually inferred, and the choice is logged and kept on spec.polarization / spec.polarization_from. A vector (curl-curl) list carries it in its incident field E_inc. A scalar Helmholtz list is solved in the polarization for which Maxwell reduces exactly to the written equation -- E along the axis every layer is invariant along, when the incidence has no component along it (a 1-D grating: E parallel to its lines; TE) -- so jno.rcwa and jno.fem answer the same equation. A stack patterned along both axes has no such polarization; it is solved x-polarized, and the log says so.

Returns

_RcwaProblem Holds the inferred :class:RcwaSpec (.spec) and a :meth:~_RcwaProblem.solve.

jno.rcwa.RcwaSpec dataclass

RcwaSpec(period: tuple, layers: list, wavelength: float, k_in: tuple = (0.0, 0.0), source_face: str = '', ambient_faces: tuple = (), periodic_axes: dict = dict(), source: dict = None, polarization: tuple = (1.0 + 0j, 0j), polarization_from: str = '')

Everything the RCWA engine needs, inferred from a jNO problem. Inspectable without fmmax.

jno.rcwa.Rcwa

Rcwa(layers, *, period, orders, wavelength=None, k_in=(0.0, 0.0), formulation='JONES_DIRECT_FOURIER', assume_periodic=False, polarization='x')

A periodic layered RCWA problem with an explicit layer stack (the backend engine).

Most users reach this through :func:jno.rcwa with a problem list; construct it directly only when you already have the [(thickness, eps), ...] stack, period and truncation in hand.

solve

solve(inc=None, wavelength=None, k_in=None, layers=None, source=None, polarization=None)

Solve the stack and return a :class:_Sol. Raises if the wavelength is unknown or energy is not conserved.

layers optionally overrides the construction layer stack with [(thickness, eps), ...] at solve time -- pass JAX permittivity grids here to differentiate the solve in the design (the construction-time shape guards run once, eagerly, so the solve itself stays trace-clean).

source (a dict built by the front door) drives an internal-source emission solve instead of plane-wave incidence -- see :meth:_solve_source; it returns an :class:_EmitterSol.

jno.rcwa.detect_layers

detect_layers(E, z, tol=0.001, slices=None, locate=None)

Group a z-sampled permittivity into RCWA layers.

Parameters

E: (Nz, Ng, Ng) permittivity sampled on the unit-cell grid at each height z. z: (Nz,) ascending heights. tol: In-plane change below which two adjacent z-slices are the same material. slices: If given, staircase a continuously-varying eps into this many layers instead of raising. locate: locate(z_lo, z_hi) -> z places the interface between two neighbouring layers, given heights inside each (their middle samples). Without it the interface is put halfway between the last sample of one layer and the first of the next, so a thickness is only known to one sample spacing. The front door passes a bisection on the permittivity itself, which finds a sharp interface exactly and a mesh field's interface where its interpolant crosses halfway between the layers.

A layer's thickness is the distance between the interfaces that bound it -- not between its first and last samples, which is short by up to one spacing (a 0.2 slab sampled every 1/63 came out 11/63 = 0.175 thick, and its reflectance 0.34 against Airy's 0.62).

Returns

list[tuple[float, np.ndarray]] [(thickness, eps_xy), ...] super→substrate; the two ambients have thickness inf.

Raises

RcwaError If eps varies continuously in z and slices is None (RCWA needs invariant slabs).