Conductor and dielectric losses#

Every structure in this series so far was lossless: perfect metal, perfect dielectrics. That is the right starting point — S-parameters of well-matched devices are dominated by geometry — but real answers to real questions need dissipation: how many dB does this cable eat, how hot does this filter run, what is the Q of this cavity?

Loss enters an electromagnetic model in two physically distinct places, and Magnelio treats them differently:

  • Dielectric loss happens in the volume. A lossy material is simply part of the field solution — declare it, and every result (S-parameters included) carries the loss.

  • Conductor loss happens in a skin depth of micrometres, far below any reasonable cell size. It comes in two models: the default treats walls as perfect conductors and books the dissipated power afterwards from the tangential magnetic field (fast, and exactly what a resonator Q needs) — or wall_model="sibc" folds a surface impedance into the time stepping itself, so that the S-parameters come out lossy.

This tutorial exercises all three paths, each against a closed-form reference: a coax filled with a lossy PCB-class dielectric, a stainless-steel waveguide, and the Q of a copper cavity.

A dielectric from datasheet numbers#

Substrate datasheets give two numbers: the relative permittivity and the loss tangent, both at a reference frequency — say εᵣ = 4.3 and tan δ = 0.02 at 3 GHz for an FR-4-class laminate. It is tempting to read that as “tan δ = 0.02 at every frequency”, but such a material cannot exist: causality (the Kramers–Kronig relations) demands that a constant loss angle is paid for with a logarithmic fall of εᵣ over frequency. The standard causal reading of a datasheet — used by every time-domain solver — is the wideband Debye model: a chain of relaxation poles spread over many decades that holds tan δ nearly constant across the band while εᵣ drifts exactly as physics requires.

djordjevic_sarkar() builds that model from the two datasheet numbers alone.

import matplotlib.pyplot as plt
import numpy as np

import magnelio as mio
from magnelio import geo, materials, monitors, ports, post
from magnelio.constants import *

eps_r, tan_d, f_ref = 4.3, 0.02, 3e9

model_d = materials.DispersionModel.djordjevic_sarkar(eps_r, tan_d, f_ref=f_ref)
fr4 = mio.Material.dispersive("FR-4 class", model_d)
print(f"{len(model_d.poles)} relaxation poles, eps_inf = {model_d.eps_inf:.3f}")
19 relaxation poles, eps_inf = 3.950

The model spreads its relaxation over nine decades — evaluated inside our band it holds tan δ within ±2 % of the datasheet value. Its high-frequency limit eps_inf ≈ 3.95 is below the in-band 4.3: that is the Kramers–Kronig toll, not an error.

The test vehicle is the RG-58 coax geometry from the earlier tutorials, with the polyethylene swapped for this material. At these loss levels 24 mm of line eats a measurable half dB.

r_i, r_o, L_coax = 0.405e-3, 1.475e-3, 24e-3
f_max = 6e9

pec = mio.Material.pec()
dielectric = geo.Cylinder(origin=(0, 0, 0), radius=r_o, height=L_coax, axis="z", material=fr4)
inner = geo.Cylinder(origin=(0, 0, 0), radius=r_i, height=L_coax, axis="z", material=pec)

model = mio.GeometryModel(background=pec)
model.add(geo.Difference(dielectric, inner))
model.add(inner)
model.add_port(ports.PortWaveguide(name="port1", plane="zmin"))
model.add_port(ports.PortWaveguide(name="port2", plane="zmax"))

mesh = mio.Mesh.from_geometry(
    model, mio.MeshControl(max_cell_size=0.16e-3, min_cell_size=0.16e-3), f_max=f_max
)

result = mio.AnalysisScatteringTD(mesh=mesh, f_max=f_max, verbose=False).run(excited=["port1"])

On a uniform matched line, |S21| = e^(−αL), so one transmission measurement hands us the attenuation constant. Two closed forms to compare against: the exact α of the wideband Debye model (from its complex permittivity), and the textbook low-loss formula α_d = π·f·√εᵣ·tan δ / c for a constant loss tangent — on this material the two are nearly indistinguishable, which is the point of the causal model.

One honest footnote: the port launches the mode computed at the material’s high-frequency permittivity, so on a strongly dispersive line the port is slightly mismatched to the in-band wave — here |S11| stays below −26 dB and shows up only as a gentle ripple.

f = result.f_axis
alpha_num = -np.log(np.abs(result.S("port2", "port1"))) / L_coax

eps_w = model_d.evaluate(2 * np.pi * f)
alpha_model = (2j * np.pi * f / C0 * np.sqrt(eps_w)).real
alpha_textbook = np.pi * f * np.sqrt(eps_r) * tan_d / C0

NP2DB = 20 / np.log(10)  # nepers -> dB
band = (f > 1e9) & (f < 6e9)

fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(f[band] / 1e9, NP2DB * alpha_num[band], "o-", ms=3, label="simulated, from |S21|")
ax.plot(f[band] / 1e9, NP2DB * alpha_model[band], label="wideband Debye model, exact")
ax.plot(
    f[band] / 1e9,
    NP2DB * alpha_textbook[band],
    "--",
    label=r"textbook  $\pi f \sqrt{\epsilon_r} \tan\delta \,/\, c$",
)
ax.set_xlabel("frequency (GHz)")
ax.set_ylabel(r"dielectric attenuation $\alpha_d$ (dB/m)")
ax.set_title("Coax filled with an FR-4-class dielectric")
ax.grid(alpha=0.3)
ax.legend()
fig.tight_layout()

k5 = int(np.argmin(np.abs(f - 5e9)))
print(f"alpha at 5 GHz: {NP2DB * alpha_num[k5]:.1f} dB/m (model: {NP2DB * alpha_model[k5]:.1f})")
Coax filled with an FR-4-class dielectric
alpha at 5 GHz: 18.7 dB/m (model: 19.0)

From 1.6 GHz up the simulated attenuation tracks the model within a few percent; below that, α·L shrinks toward the ripple floor and the extraction gets noisy — measuring a hundredth of a dB rides on the port match, in simulation exactly as on a bench.

Conductor loss: one physics, two models#

Metal walls dissipate in a skin depth — copper at 10 GHz: 0.66 µm. No volume grid resolves that, and none has to: the loss per area follows from the tangential magnetic field and the surface resistance R_s = √(πfµ/σ) alone. What differs is when that formula is applied.

The perturbative default (used automatically by the wall-loss tools) keeps every wall a perfect conductor during the run and books ½·R_s·|H_tan|² per wall patch afterwards. The fields — and the S-parameters — stay lossless.

The surface-impedance boundary (wall_model="sibc") replaces the perfect-conductor wall by its causal surface impedance inside the time stepping. The wave loses energy as it propagates, and |S21| carries the attenuation.

The fixture: a WR-90 rectangular waveguide, 100 mm of it, in stainless steel (σ = 1.45·10⁶ S/m — about 6× the surface resistance of copper, a realistic worst case and comfortably measurable). Flat walls also mean the closed form is trustworthy across the band.

a_wg, b_wg, L_wg = 22.86e-3, 10.16e-3, 100e-3
sigma_steel = 1.45e6
f_lo, f_hi = 8.2e9, 12.4e9

air = mio.Material.air()
wg = mio.GeometryModel(background=air)
wg.add(geo.Brick(origin=(0, 0, 0), size=(a_wg, b_wg, L_wg), material=air))
wg.add_port(ports.PortWaveguide(name="port1", plane="zmin", n_modes=1))
wg.add_port(ports.PortWaveguide(name="port2", plane="zmax", n_modes=1))

wg_mesh = mio.Mesh.from_geometry(wg, mio.MeshControl(min_nodes_per_wavelength=18), f_max=f_hi)


def run_waveguide(**wall_kwargs):
    monitor = monitors.MonitorWallLoss(
        freqs=np.linspace(f_lo, f_hi, 9),
        reference_plane=("z", 5e-3),
        sigma=sigma_steel,
        bc_faces=("xmin", "xmax", "ymin", "ymax"),
    )
    analysis = mio.AnalysisScatteringTD(
        mesh=wg_mesh,
        f_min=f_lo,
        f_max=f_hi,
        monitors=(monitor,),
        verbose=False,
        **wall_kwargs,
    )
    return analysis.run(excited=["port1"]), monitor

First the default. The monitor needs to know which conductor it is metering — the walls here are the domain boundary, so bc_faces lists them and sigma supplies their conductivity (a lossy-metal solid would carry its own). The reference plane turns the booked wall power into a fraction of the power flowing down the guide.

res_pec, mon_pec = run_waveguide()

s21_pec = np.abs(res_pec.S("port2", "port1"))
print(f"perturbative run:  max | |S21| - 1 |  = {np.abs(s21_pec - 1).max():.1e}")

frac = mon_pec.dissipated_fraction["total"]
alpha_pert = frac / (2 * L_wg)  # fraction ~ 2*alpha*L for small loss
perturbative run:  max | |S21| - 1 |  = 1.9e-04

|S21| = 1 to a few 10⁻⁴: the run itself was lossless, yet the monitor already knows the walls would eat about 2 % of the power — the perturbative bargain in one sentence. (The factor 2: the fraction is a power ratio, α is a field amplitude rate.)

Now the same guide with the surface impedance in the update.

res_sibc, mon_sibc = run_waveguide(wall_model="sibc", wall_sigma=sigma_steel)

f_wg = res_sibc.f_axis
alpha_sibc = -np.log(np.abs(res_sibc.S("port2", "port1"))) / L_wg

k = 2 * np.pi * f_wg / C0
beta = np.sqrt(np.maximum(k**2 - (np.pi / a_wg) ** 2, 0.0))
R_s = np.sqrt(np.pi * f_wg * MU0 / sigma_steel)
with np.errstate(divide="ignore", invalid="ignore"):
    alpha_te10 = R_s / (a_wg**3 * b_wg * beta * k * ETA0) * (2 * b_wg * np.pi**2 + a_wg**3 * k**2)

in_band = (f_wg > f_lo) & (f_wg < f_hi)

fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(
    f_wg[in_band] / 1e9,
    NP2DB * alpha_sibc[in_band],
    label='wall_model="sibc", from |S21|',
)
ax.plot(f_wg[in_band] / 1e9, NP2DB * alpha_te10[in_band], "--", label="TE10 closed form")
ax.plot(
    mon_pec.f / 1e9,
    NP2DB * alpha_pert,
    "s",
    ms=5,
    fillstyle="none",
    label="perturbative monitor, lossless run",
)
ax.set_xlabel("frequency (GHz)")
ax.set_ylabel(r"conductor attenuation $\alpha_c$ (dB/m)")
ax.set_title("WR-90 waveguide in stainless steel")
ax.grid(alpha=0.3)
ax.legend()
fig.tight_layout()

ratio = alpha_sibc[in_band] / alpha_te10[in_band]
print(f"sibc vs closed form across the band: {ratio.min():.3f} .. {ratio.max():.3f}")
WR-90 waveguide in stainless steel
sibc vs closed form across the band: 0.969 .. 1.009

Three independent estimates of the same physics — the surface impedance marching in the update, the perturbative bookkeeping on a lossless run, and the closed form — agree within two percent.

Which model when? If the S-parameters must carry the loss (an attenuator, insertion loss of a filter, anything driven), use wall_model="sibc". If the question is a loss budget or a Q, the perturbative tools answer it from a lossless run — and several material variants can be evaluated from one solve, since the fields never change. One caveat travels with both models on a Cartesian grid: sharply curved conductors need a handful of cells across the radius before the sampled wall field — and with it α — is trustworthy; the flat walls here book exactly.

The Q of a cavity#

For resonators the natural loss figure is the quality factor. The eigenmode solver finds the lossless modes exactly as in the eigenmode tutorial; wall_loss_Q() then books the perturbative wall loss of one mode and returns Q = ω·W / P_loss. The reference: the classic closed form for the TE₁₀₁ mode of a rectangular copper cavity.

a_c, b_c, d_c = 20e-3, 10e-3, 25e-3
sigma_cu = 5.8e7

cavity = mio.GeometryModel(background=air)
cavity.add(geo.Brick(origin=(0, 0, 0), size=(a_c, b_c, d_c), material=air))
f101 = C0 / 2 * np.sqrt((1 / a_c) ** 2 + (1 / d_c) ** 2)

cav_mesh = mio.Mesh.from_geometry(
    cavity, mio.MeshControl(min_nodes_per_wavelength=20), f_max=1.2 * f101
)
modes = mio.AnalysisEigenmode(mesh=cav_mesh, n_modes=5, verbose=False).run()

q = post.wall_loss_Q(modes, mode=0, sigma=sigma_cu)

k101 = 2 * np.pi * modes.frequencies[0] / C0
R_s_cu = np.sqrt(np.pi * modes.frequencies[0] * MU0 / sigma_cu)
Q_ref = (
    (k101 * a_c * d_c) ** 3
    * b_c
    * ETA0
    / (2 * np.pi**2 * R_s_cu * (2 * a_c**3 * b_c + 2 * b_c * d_c**3 + a_c**3 * d_c + a_c * d_c**3))
)
print(f"TE101 at {modes.frequencies[0] / 1e9:.3f} GHz (analytical {f101 / 1e9:.3f} GHz)")
print(f"wall_loss_Q: {q.Q:.0f}   closed form: {Q_ref:.0f}   ratio {q.Q / Q_ref:.3f}")
TE101 at 9.585 GHz (analytical 9.598 GHz)
wall_loss_Q: 7740   closed form: 7687   ratio 1.007

Within one percent of the closed form. The result also knows where the power goes — per_tag splits the wall loss by face, and the breakdown mirrors the mode: the two broad walls see the full tangential magnetic field of TE₁₀₁ and take half the loss between them; the four side walls, each touched by only one H component, share the rest.

tags = sorted(q.per_tag, key=q.per_tag.get, reverse=True)
shares = [q.per_tag[t] / sum(q.per_tag.values()) for t in tags]

fig, ax = plt.subplots(figsize=(7, 3.8))
ax.bar(range(len(tags)), [100 * s for s in shares], color="C0")
ax.set_xticks(range(len(tags)), tags)
ax.set_ylabel("share of wall loss (%)")
ax.set_title(f"TE$_{{101}}$ copper cavity — Q = {q.Q:.0f}, per-wall loss breakdown")
ax.grid(alpha=0.3, axis="y")
fig.tight_layout()
TE$_{101}$ copper cavity — Q = 7740, per-wall loss breakdown

Where to go from here#

Everything above extends without new concepts:

  • Lossy metal solids. lossy_metal() declares a conductor with its σ; both wall-loss models pick the value up from the material, no override needed.

  • Surface roughness. Real copper foil is rough, and roughness raises the effective surface resistance — by a factor approaching 2 at millimetre-wave frequencies. The standard correction models (Hammerstad, Huray) plug into the same roughness / wall_roughness hooks of every path shown here.

  • Dielectric loss in any structure. The wideband Debye material from the first section is an ordinary material — put it under a microstrip or into a filter and the S-parameters carry the loss automatically.

The next tutorial leaves single simulations behind: parameter sweeps and optimization with ordinary Python loops.

Total running time of the script: (0 minutes 27.779 seconds)

Gallery generated by Sphinx-Gallery